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
This commit is contained in:
@@ -0,0 +1,50 @@
|
|||||||
|
// RA-H OS Bridge Schema Validation Middleware
|
||||||
|
function validateNodePayload(payload) {
|
||||||
|
const errors = [];
|
||||||
|
const required = ['tenant', 'namespace', 'type', 'visibility', 'state', 'captured_by'];
|
||||||
|
const defaults = {
|
||||||
|
tenant: 'syslogsolution',
|
||||||
|
namespace: 'syslogsolution',
|
||||||
|
type: 'note',
|
||||||
|
visibility: 'shared',
|
||||||
|
state: 'not_processed',
|
||||||
|
captured_by: 'agent'
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const field of required) {
|
||||||
|
payload.metadata = payload.metadata || {};
|
||||||
|
if (!payload.metadata[field]) {
|
||||||
|
if (defaults[field]) payload.metadata[field] = defaults[field];
|
||||||
|
else errors.push('Missing required field: ' + field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also ensure source_metadata exists for compatibility
|
||||||
|
if (!payload.metadata.source_metadata) {
|
||||||
|
payload.metadata.source_metadata = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateEdgePayload(payload) {
|
||||||
|
const errors = [];
|
||||||
|
if (!payload.explanation) {
|
||||||
|
if (payload.context?.explanation) payload.explanation = payload.context.explanation;
|
||||||
|
else errors.push('Missing required field: explanation');
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validationMiddleware(operation, payload) {
|
||||||
|
let errors = [];
|
||||||
|
if (['createNode', 'updateNode'].includes(operation)) errors = validateNodePayload(payload);
|
||||||
|
else if (operation === 'createEdge') errors = validateEdgePayload(payload);
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
console.error('[BRIDGE_VALIDATION] ' + operation + ' rejected:', { errors: errors, timestamp: new Date().toISOString() });
|
||||||
|
return { success: false, errors: errors };
|
||||||
|
}
|
||||||
|
return { success: true, payload: payload };
|
||||||
|
}
|
||||||
|
module.exports = { validationMiddleware };
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
'use strict';
|
const { validationMiddleware } = require("./bridge-validation.js");
|
||||||
|
|
||||||
// Check Node version early — better-sqlite3 native bindings don't support bleeding-edge Node
|
// Check Node version early — better-sqlite3 native bindings don't support bleeding-edge Node
|
||||||
const nodeVersion = parseInt(process.versions.node.split('.')[0], 10);
|
const nodeVersion = parseInt(process.versions.node.split('.')[0], 10);
|
||||||
@@ -26,7 +26,7 @@ try {
|
|||||||
|
|
||||||
const { z } = require('zod');
|
const { z } = require('zod');
|
||||||
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
|
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
|
||||||
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js');
|
||||||
const packageJson = require('./package.json');
|
const packageJson = require('./package.json');
|
||||||
|
|
||||||
const { initDatabase, getDatabasePath, closeDatabase, getDb, query } = require('./services/sqlite-client');
|
const { initDatabase, getDatabasePath, closeDatabase, getDb, query } = require('./services/sqlite-client');
|
||||||
@@ -105,7 +105,7 @@ const addNodeInputSchema = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const searchNodesInputSchema = {
|
const searchNodesInputSchema = {
|
||||||
query: z.string().min(1).max(400).describe('Search query'),
|
query: z.string().max(400).optional().describe('Search query'),
|
||||||
limit: z.number().min(1).max(50).optional().describe('Max results (default 10)'),
|
limit: z.number().min(1).max(50).optional().describe('Max results (default 10)'),
|
||||||
created_after: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created on or after this date.'),
|
created_after: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created on or after this date.'),
|
||||||
created_before: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created before this date.'),
|
created_before: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created before this date.'),
|
||||||
@@ -184,7 +184,8 @@ function sanitizeFtsQuery(input) {
|
|||||||
.trim()
|
.trim()
|
||||||
.split(/\s+/)
|
.split(/\s+/)
|
||||||
.filter(w => w.length > 0 && !/^(AND|OR|NOT|NEAR)$/i.test(w))
|
.filter(w => w.length > 0 && !/^(AND|OR|NOT|NEAR)$/i.test(w))
|
||||||
.join(' ');
|
.map(w => `"${w}"`)
|
||||||
|
.join(' OR ');
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ftsAvailability = null;
|
let _ftsAvailability = null;
|
||||||
@@ -235,10 +236,6 @@ function log(...args) {
|
|||||||
console.error('[ra-h-standalone]', ...args);
|
console.error('[ra-h-standalone]', ...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
function withVisibleJson(summary, payload) {
|
|
||||||
return `${summary}\n\n${JSON.stringify(payload, null, 2)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
// Initialize database
|
// Initialize database
|
||||||
try {
|
try {
|
||||||
@@ -311,7 +308,7 @@ async function main() {
|
|||||||
: result.reason;
|
: result.reason;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text', text: result.shouldRetrieve ? withVisibleJson(summary, result) : summary }],
|
content: [{ type: 'text', text: summary }],
|
||||||
structuredContent: result
|
structuredContent: result
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -328,12 +325,17 @@ async function main() {
|
|||||||
const sourceText = source?.trim() || content?.trim() || chunk?.trim();
|
const sourceText = source?.trim() || content?.trim() || chunk?.trim();
|
||||||
const normalizedDescription = typeof description === 'string' ? description.trim() : description;
|
const normalizedDescription = typeof description === 'string' ? description.trim() : description;
|
||||||
|
|
||||||
|
// LINT GUARD: Reject writes with metadata.tenant (deprecated — use namespace)
|
||||||
|
if (metadata && metadata.tenant) {
|
||||||
|
throw new Error('metadata.tenant is deprecated. Use metadata.namespace only.');
|
||||||
|
}
|
||||||
|
const r = validationMiddleware("createNode", { title, source, link, description, metadata }); if (!r.success) throw new Error(r.errors.join(", "));
|
||||||
const node = nodeService.createNode({
|
const node = nodeService.createNode({
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
source: sourceText,
|
source: sourceText,
|
||||||
link: link?.trim(),
|
link: link?.trim(),
|
||||||
description: normalizedDescription,
|
description: normalizedDescription,
|
||||||
metadata: metadata || {}
|
metadata: r.payload?.metadata || {}
|
||||||
});
|
});
|
||||||
|
|
||||||
const summary = `Created node #${node.id}: ${node.title}`;
|
const summary = `Created node #${node.id}: ${node.title}`;
|
||||||
@@ -359,7 +361,7 @@ async function main() {
|
|||||||
async ({ query: searchQuery, limit = 10, created_after, created_before, event_after, event_before }) => {
|
async ({ query: searchQuery, limit = 10, created_after, created_before, event_after, event_before }) => {
|
||||||
const safeLimit = Math.min(Math.max(limit, 1), 50);
|
const safeLimit = Math.min(Math.max(limit, 1), 50);
|
||||||
const result = directNodeLookup({
|
const result = directNodeLookup({
|
||||||
search: searchQuery.trim(),
|
search: (searchQuery ?? "").trim(),
|
||||||
limit: safeLimit,
|
limit: safeLimit,
|
||||||
createdAfter: created_after,
|
createdAfter: created_after,
|
||||||
createdBefore: created_before,
|
createdBefore: created_before,
|
||||||
@@ -367,28 +369,26 @@ async function main() {
|
|||||||
eventBefore: event_before,
|
eventBefore: event_before,
|
||||||
});
|
});
|
||||||
|
|
||||||
const mappedNodes = result.nodes.map((node) => ({
|
|
||||||
id: node.id,
|
|
||||||
title: node.title,
|
|
||||||
source: node.source ?? null,
|
|
||||||
description: node.description ?? null,
|
|
||||||
link: node.link ?? null,
|
|
||||||
created_at: node.created_at,
|
|
||||||
updated_at: node.updated_at,
|
|
||||||
event_date: node.event_date ?? null,
|
|
||||||
}));
|
|
||||||
const payload = {
|
|
||||||
count: result.count,
|
|
||||||
filters_applied: result.filtersApplied,
|
|
||||||
nodes: mappedNodes
|
|
||||||
};
|
|
||||||
const summary = result.count === 0
|
const summary = result.count === 0
|
||||||
? 'No nodes found matching that query.'
|
? 'No nodes found matching that query.'
|
||||||
: `Found ${result.count} node(s).`;
|
: `Found ${result.count} node(s).`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text', text: result.count === 0 ? summary : withVisibleJson(summary, payload) }],
|
content: [{ type: 'text', text: summary }],
|
||||||
structuredContent: payload
|
structuredContent: {
|
||||||
|
count: result.count,
|
||||||
|
filters_applied: result.filtersApplied,
|
||||||
|
nodes: result.nodes.map((node) => ({
|
||||||
|
id: node.id,
|
||||||
|
title: node.title,
|
||||||
|
source: node.source ?? null,
|
||||||
|
description: node.description ?? null,
|
||||||
|
link: node.link ?? null,
|
||||||
|
created_at: node.created_at,
|
||||||
|
updated_at: node.updated_at,
|
||||||
|
event_date: node.event_date ?? null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -470,7 +470,32 @@ async function main() {
|
|||||||
: mappedUpdates.description;
|
: mappedUpdates.description;
|
||||||
}
|
}
|
||||||
|
|
||||||
const node = nodeService.updateNode(id, mappedUpdates);
|
|
||||||
|
// === TENANT ENFORCEMENT: verify node has namespace/tenant before allowing update ===
|
||||||
|
const checkNode = nodeService.getNodeById(id);
|
||||||
|
if (checkNode) {
|
||||||
|
let meta = checkNode.metadata || {};
|
||||||
|
let tenant = null;
|
||||||
|
if (typeof meta === 'string') {
|
||||||
|
try { meta = JSON.parse(meta); } catch(e) { meta = {}; }
|
||||||
|
}
|
||||||
|
if (!meta) meta = {};
|
||||||
|
tenant = meta.namespace || null;
|
||||||
|
// Bootstrap: allow updates that SET namespace even on NULL-namespace nodes
|
||||||
|
// Handle both flat and nested metadata formats
|
||||||
|
const metaUpdate = mappedUpdates.metadata || {};
|
||||||
|
const isSettingNamespace = metaUpdate.namespace && (
|
||||||
|
typeof metaUpdate.namespace === 'string' ||
|
||||||
|
(typeof metaUpdate.namespace === 'object' && metaUpdate.namespace.value)
|
||||||
|
);
|
||||||
|
if (!tenant && !isSettingNamespace) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `ERROR: Node #${id} has no namespace. Blocked by namespace enforcement. Set metadata.namespace in this update.` }],
|
||||||
|
structuredContent: { success: false, nodeId: id, error: 'namespace_required' }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const node = nodeService.updateNode(id, mappedUpdates);
|
||||||
const message = `Updated node #${id}`;
|
const message = `Updated node #${id}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -497,6 +522,7 @@ async function main() {
|
|||||||
if (!confirmed_by_user) {
|
if (!confirmed_by_user) {
|
||||||
throw new Error('createEdge requires explicit user confirmation before writing the relationship.');
|
throw new Error('createEdge requires explicit user confirmation before writing the relationship.');
|
||||||
}
|
}
|
||||||
|
const r = validationMiddleware("createEdge", { sourceId, targetId, explanation }); if (!r.success) throw new Error(r.errors.join(", "));
|
||||||
|
|
||||||
const edge = edgeService.createEdge({
|
const edge = edgeService.createEdge({
|
||||||
from_node_id: sourceId,
|
from_node_id: sourceId,
|
||||||
@@ -563,7 +589,7 @@ async function main() {
|
|||||||
from_node_id: e.from_node_id,
|
from_node_id: e.from_node_id,
|
||||||
to_node_id: e.to_node_id,
|
to_node_id: e.to_node_id,
|
||||||
type: e.context?.type ?? null,
|
type: e.context?.type ?? null,
|
||||||
explanation: e.explanation ?? e.context?.explanation ?? null
|
explanation: e.context?.explanation ?? null
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -820,10 +846,99 @@ async function main() {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
// ==================== getAllNodes tool ====================
|
||||||
|
registerToolWithAliases(
|
||||||
|
'getAllNodes',
|
||||||
|
{
|
||||||
|
title: 'Get all RA-H nodes',
|
||||||
|
description: 'Return all nodes in the graph with their metadata. Supports pagination via limit/offset. Use this for bulk operations, audits, and stale node detection. NOT for searching — use queryNodes for keyword search.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
limit: z.number().min(1).max(500).optional().default(100).describe('Max nodes to return'),
|
||||||
|
offset: z.number().min(0).optional().default(0).describe('Offset for pagination'),
|
||||||
|
namespace: z.string().optional().describe('Filter by namespace'),
|
||||||
|
tenant: z.string().optional().describe('Filter by tenant (syslogsolution, homelab, shared, personal)'),
|
||||||
|
state: z.string().optional().describe('Filter by state (active, not_processed, etc.)')
|
||||||
|
}).optional().default({})
|
||||||
|
},
|
||||||
|
async ({ limit = 100, offset = 0, namespace, tenant, state }) => {
|
||||||
|
const safeLimit = Math.min(Math.max(limit, 1), 500);
|
||||||
|
const safeOffset = Math.max(offset, 0);
|
||||||
|
|
||||||
|
// Build SQL with optional tenant filter
|
||||||
|
let sql = "SELECT n.id, n.title, n.description, n.metadata, n.created_at, n.updated_at FROM nodes n";
|
||||||
|
let countSql = "SELECT COUNT(*) as count FROM nodes n";
|
||||||
|
const conditions = [];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (tenant) {
|
||||||
|
conditions.push("json_extract(n.metadata, '$.namespace') = ?");
|
||||||
|
params.push(tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditions.length > 0) {
|
||||||
|
const where = " WHERE " + conditions.join(" AND ");
|
||||||
|
sql += where;
|
||||||
|
countSql += where;
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += " ORDER BY n.updated_at DESC LIMIT " + safeLimit + " OFFSET " + safeOffset;
|
||||||
|
|
||||||
|
const rows = query(sql, params);
|
||||||
|
const totalRow = query(countSql, params);
|
||||||
|
const total = totalRow[0].count;
|
||||||
|
|
||||||
|
let nodes = rows.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
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (namespace) {
|
||||||
|
nodes = nodes.filter(n =>
|
||||||
|
(n.metadata?.namespace || "").toLowerCase() === namespace.toLowerCase()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state) {
|
||||||
|
nodes = nodes.filter(n =>
|
||||||
|
(n.metadata?.state || "").toLowerCase() === state.toLowerCase()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "Retrieved " + nodes.length + " of " + total + " node(s)." }],
|
||||||
|
structuredContent: {
|
||||||
|
total,
|
||||||
|
returned: nodes.length,
|
||||||
|
offset: safeOffset,
|
||||||
|
limit: safeLimit,
|
||||||
|
nodes: nodes.map(n => ({
|
||||||
|
id: n.id,
|
||||||
|
title: n.title,
|
||||||
|
description: n.description ?? null,
|
||||||
|
metadata: n.metadata ?? null,
|
||||||
|
created_at: n.created_at,
|
||||||
|
updated_at: n.updated_at
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Connect transport
|
// Connect transport
|
||||||
const transport = new StdioServerTransport();
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
||||||
|
const http = require('http');
|
||||||
|
const httpServer = http.createServer(async (req, res) => {
|
||||||
|
await transport.handleRequest(req, res);
|
||||||
|
});
|
||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
log('MCP server ready');
|
log('MCP server ready');
|
||||||
|
httpServer.listen(3100, '0.0.0.0', () => {
|
||||||
|
log('HTTP server listening on port 3100');
|
||||||
|
});
|
||||||
|
|
||||||
// Handle graceful shutdown
|
// Handle graceful shutdown
|
||||||
process.on('SIGINT', () => {
|
process.on('SIGINT', () => {
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ function getEdges(filters = {}) {
|
|||||||
|
|
||||||
const rows = query(sql, params);
|
const rows = query(sql, params);
|
||||||
|
|
||||||
return rows.map(formatEdgeRow);
|
return rows.map(row => ({
|
||||||
|
...row,
|
||||||
|
context: parseContext(row.context)
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,7 +34,11 @@ function getEdgeById(id) {
|
|||||||
const rows = query('SELECT * FROM edges WHERE id = ?', [id]);
|
const rows = query('SELECT * FROM edges WHERE id = ?', [id]);
|
||||||
if (rows.length === 0) return null;
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
return formatEdgeRow(rows[0]);
|
const row = rows[0];
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
context: parseContext(row.context)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,7 +59,21 @@ function createEdge(edgeData) {
|
|||||||
throw new Error('Edge explanation is required');
|
throw new Error('Edge explanation is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanExplanation = explanation.trim();
|
// P1 — Dedup check before edge creation
|
||||||
|
const existing = db.prepare(
|
||||||
|
`SELECT id FROM edges
|
||||||
|
WHERE from_node_id = ?
|
||||||
|
AND to_node_id = ?
|
||||||
|
AND (explanation = ? OR json_extract(context, '$.explanation') = ?)`
|
||||||
|
).get(from_node_id, to_node_id, explanation.trim(), explanation.trim());
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return {
|
||||||
|
id: existing.id,
|
||||||
|
status: 'existing',
|
||||||
|
warning: 'duplicate_edge_skipped'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Simple context without AI inference
|
// Simple context without AI inference
|
||||||
// The main app can re-infer types when it loads
|
// The main app can re-infer types when it loads
|
||||||
@@ -60,13 +81,13 @@ function createEdge(edgeData) {
|
|||||||
type: 'related_to',
|
type: 'related_to',
|
||||||
confidence: 0.5,
|
confidence: 0.5,
|
||||||
inferred_at: now,
|
inferred_at: now,
|
||||||
explanation: cleanExplanation,
|
explanation: explanation.trim(),
|
||||||
created_via: 'mcp'
|
created_via: 'mcp'
|
||||||
};
|
};
|
||||||
|
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
INSERT INTO edges (from_node_id, to_node_id, context, source, created_at, explanation)
|
INSERT INTO edges (from_node_id, to_node_id, context, source, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const result = runWithBusyRetry(() => stmt.run(
|
const result = runWithBusyRetry(() => stmt.run(
|
||||||
@@ -74,13 +95,18 @@ function createEdge(edgeData) {
|
|||||||
to_node_id,
|
to_node_id,
|
||||||
JSON.stringify(context),
|
JSON.stringify(context),
|
||||||
source,
|
source,
|
||||||
now,
|
now
|
||||||
cleanExplanation
|
|
||||||
),
|
),
|
||||||
'createEdge'
|
'createEdge'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// P0 — After writing edge to DB, sync explanation column
|
||||||
const edgeId = Number(result.lastInsertRowid);
|
const edgeId = Number(result.lastInsertRowid);
|
||||||
|
if (context.explanation) {
|
||||||
|
const syncStmt = db.prepare('UPDATE edges SET explanation = ? WHERE id = ?');
|
||||||
|
runWithBusyRetry(() => syncStmt.run(context.explanation, edgeId), 'createEdge_explanation_sync');
|
||||||
|
}
|
||||||
|
|
||||||
return getEdgeById(edgeId);
|
return getEdgeById(edgeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,36 +122,29 @@ function updateEdge(id, updates) {
|
|||||||
throw new Error(`Edge with ID ${id} not found. Use rah_query_edges to find edges by node ID.`);
|
throw new Error(`Edge with ID ${id} not found. Use rah_query_edges to find edges by node ID.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanExplanation = typeof explanation === 'string' ? explanation.trim() : '';
|
// If explanation changed, update context
|
||||||
|
if (explanation && explanation.trim()) {
|
||||||
// If explanation changed, update both the normal field and the compatibility copy.
|
|
||||||
if (cleanExplanation) {
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const newContext = {
|
const newContext = {
|
||||||
...existing.context,
|
...existing.context,
|
||||||
explanation: cleanExplanation,
|
explanation: explanation.trim(),
|
||||||
inferred_at: now,
|
inferred_at: now,
|
||||||
created_via: 'mcp'
|
created_via: 'mcp'
|
||||||
};
|
};
|
||||||
|
|
||||||
const stmt = db.prepare('UPDATE edges SET context = ?, explanation = ? WHERE id = ?');
|
const stmt = db.prepare('UPDATE edges SET context = ? WHERE id = ?');
|
||||||
runWithBusyRetry(() => stmt.run(JSON.stringify(newContext), cleanExplanation, id), 'updateEdge');
|
runWithBusyRetry(() => stmt.run(JSON.stringify(newContext), id), 'updateEdge');
|
||||||
|
|
||||||
|
// P0 — After updating edge, sync explanation column
|
||||||
|
const syncStmt = db.prepare('UPDATE edges SET explanation = ? WHERE id = ?');
|
||||||
|
runWithBusyRetry(() => syncStmt.run(explanation.trim(), id), 'updateEdge_explanation_sync');
|
||||||
} else if (contextUpdates) {
|
} else if (contextUpdates) {
|
||||||
const contextExplanation = typeof contextUpdates.explanation === 'string'
|
|
||||||
? contextUpdates.explanation.trim()
|
|
||||||
: '';
|
|
||||||
const newContext = {
|
const newContext = {
|
||||||
...existing.context,
|
...existing.context,
|
||||||
...contextUpdates,
|
...contextUpdates
|
||||||
...(contextExplanation ? { explanation: contextExplanation } : {})
|
|
||||||
};
|
};
|
||||||
if (contextExplanation) {
|
const stmt = db.prepare('UPDATE edges SET context = ? WHERE id = ?');
|
||||||
const stmt = db.prepare('UPDATE edges SET context = ?, explanation = ? WHERE id = ?');
|
runWithBusyRetry(() => stmt.run(JSON.stringify(newContext), id), 'updateEdge');
|
||||||
runWithBusyRetry(() => stmt.run(JSON.stringify(newContext), contextExplanation, id), 'updateEdge');
|
|
||||||
} else {
|
|
||||||
const stmt = db.prepare('UPDATE edges SET context = ? WHERE id = ?');
|
|
||||||
runWithBusyRetry(() => stmt.run(JSON.stringify(newContext), id), 'updateEdge');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return getEdgeById(id);
|
return getEdgeById(id);
|
||||||
@@ -211,15 +230,6 @@ function getEdgeCount() {
|
|||||||
return Number(rows[0].count);
|
return Number(rows[0].count);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatEdgeRow(row) {
|
|
||||||
const context = parseContext(row.context);
|
|
||||||
return {
|
|
||||||
...row,
|
|
||||||
context,
|
|
||||||
explanation: row.explanation || context?.explanation || null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse context JSON safely.
|
* Parse context JSON safely.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -131,22 +131,50 @@ function buildCanonicalMetadata({ existing, metadata }) {
|
|||||||
...(incoming.source_metadata && typeof incoming.source_metadata === 'object' ? incoming.source_metadata : {}),
|
...(incoming.source_metadata && typeof incoming.source_metadata === 'object' ? incoming.source_metadata : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const merged = {
|
// Sanitize a value: reject nested objects/arrays, return trimmed string or undefined
|
||||||
...prior,
|
function sanitizeValue(value) {
|
||||||
...incoming,
|
if (value === undefined || value === null) return undefined;
|
||||||
state: incoming.state === 'processed' ? 'processed' : (prior.state === 'processed' ? 'processed' : 'not_processed'),
|
if (typeof value === 'string') {
|
||||||
captured_by: incoming.captured_by || prior.captured_by || 'human',
|
const trimmed = value.trim();
|
||||||
source_metadata: sourceMetadata,
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
const type = normalizeString(incoming.type) || normalizeString(prior.type);
|
// Build merged metadata from prior and incoming, but only keep string values
|
||||||
const capturedMethod = normalizeString(incoming.captured_method) || normalizeString(prior.captured_method);
|
const merged = {};
|
||||||
|
|
||||||
if (type) merged.type = type;
|
// Start with source_metadata (always an object)
|
||||||
else delete merged.type;
|
merged.source_metadata = sourceMetadata;
|
||||||
|
|
||||||
if (capturedMethod) merged.captured_method = capturedMethod;
|
// Copy from prior: only string values
|
||||||
else delete merged.captured_method;
|
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;
|
return merged;
|
||||||
}
|
}
|
||||||
@@ -402,6 +430,36 @@ function getChunkStatusForSource(sourceText) {
|
|||||||
/**
|
/**
|
||||||
* Create a new node.
|
* 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) {
|
function createNode(nodeData) {
|
||||||
const {
|
const {
|
||||||
title: rawTitle,
|
title: rawTitle,
|
||||||
@@ -414,6 +472,34 @@ function createNode(nodeData) {
|
|||||||
|
|
||||||
const title = sanitizeTitle(rawTitle);
|
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 canonicalMetadata = buildCanonicalMetadata({ metadata });
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
@@ -449,6 +535,9 @@ function createNode(nodeData) {
|
|||||||
* Update an existing node.
|
* Update an existing node.
|
||||||
*/
|
*/
|
||||||
function updateNode(id, updates, options = {}) {
|
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 { title, description, source, link, event_date, metadata } = updates;
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
@@ -459,7 +548,32 @@ function updateNode(id, updates, options = {}) {
|
|||||||
throw new Error(`Node with ID ${id} not found. Use rah_search_nodes to find nodes by keyword.`);
|
throw new Error(`Node with ID ${id} not found. Use rah_search_nodes to find nodes by keyword.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergedMetadata = metadata !== undefined
|
// === 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 })
|
? buildCanonicalMetadata({ existing: existing.metadata, metadata })
|
||||||
: undefined;
|
: undefined;
|
||||||
const sourceWasProvided = Object.prototype.hasOwnProperty.call(updates, 'source');
|
const sourceWasProvided = Object.prototype.hasOwnProperty.call(updates, 'source');
|
||||||
@@ -510,6 +624,23 @@ function updateNode(id, updates, options = {}) {
|
|||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// === 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);
|
return getNodeById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,8 +668,10 @@ function getNodeCount() {
|
|||||||
* Returns stats, hub nodes, and recent activity.
|
* Returns stats, hub nodes, and recent activity.
|
||||||
*/
|
*/
|
||||||
function getContext() {
|
function getContext() {
|
||||||
const nodeCount = query('SELECT COUNT(*) as count FROM nodes')[0].count;
|
const nodeCountRow = query('SELECT COUNT(*) as count FROM nodes');
|
||||||
const edgeCount = query('SELECT COUNT(*) as count FROM edges')[0].count;
|
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(`
|
const recentNodes = query(`
|
||||||
SELECT n.id, n.title, n.description
|
SELECT n.id, n.title, n.description
|
||||||
@@ -556,10 +689,34 @@ function getContext() {
|
|||||||
LIMIT 10
|
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 {
|
return {
|
||||||
stats: { nodeCount, edgeCount, dimensionCount: 0 },
|
stats: { nodeCount, edgeCount, dimensionCount: 0 },
|
||||||
recentNodes,
|
recentNodes,
|
||||||
hubNodes
|
hubNodes,
|
||||||
|
allNodes,
|
||||||
|
staleNodes
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -20,8 +20,6 @@
|
|||||||
"setup": "npm install",
|
"setup": "npm install",
|
||||||
"sqlite:backup": "bash scripts/database/sqlite-backup.sh",
|
"sqlite:backup": "bash scripts/database/sqlite-backup.sh",
|
||||||
"sqlite:restore": "bash scripts/database/sqlite-restore.sh",
|
"sqlite:restore": "bash scripts/database/sqlite-restore.sh",
|
||||||
"doctor:local-ai": "tsx scripts/doctor-local-ai.ts",
|
|
||||||
"rebuild:embeddings": "tsx scripts/rebuild-embeddings.ts",
|
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"evals": "cross-env RAH_EVALS_LOG=1 RAH_EVALS_TIMEOUT_MS=60000 tsx tests/evals/runner.ts",
|
"evals": "cross-env RAH_EVALS_LOG=1 RAH_EVALS_TIMEOUT_MS=60000 tsx tests/evals/runner.ts",
|
||||||
@@ -49,6 +47,7 @@
|
|||||||
"react-dom": "^19.0.1",
|
"react-dom": "^19.0.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
|
"sqlite-vec": "^0.1.9",
|
||||||
"tiptap-markdown": "^0.9.0",
|
"tiptap-markdown": "^0.9.0",
|
||||||
"youtube-transcript": "^1.2.1",
|
"youtube-transcript": "^1.2.1",
|
||||||
"youtube-transcript-plus": "^1.1.1",
|
"youtube-transcript-plus": "^1.1.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user