feat: sync open-source context and capsule removal

- remove legacy contexts surfaces from the app, MCP bridge, standalone server, and docs
- keep getContext and retrieveQueryContext while aligning the simplified graph-only contract
- harden dev startup migration behavior and disable the accidental chat surface in open source

Generated with Codex
This commit is contained in:
“BeeRad”
2026-04-17 12:34:51 +10:00
parent 97eeb0789f
commit 07754f5030
87 changed files with 2688 additions and 4861 deletions
@@ -1,141 +0,0 @@
'use strict';
const { getDb } = require('./sqlite-client');
const MAX_CONTEXTS_PER_ACCOUNT = 10;
function mapContext(row) {
if (!row) return null;
return {
id: row.id,
name: row.name,
description: row.description ?? null,
icon: row.icon ?? null,
count: Number(row.count ?? 0),
};
}
function listContexts() {
const db = getDb();
const rows = db.prepare(`
SELECT c.id, c.name, c.description, c.icon, COUNT(n.id) AS count
FROM contexts c
LEFT JOIN nodes n ON n.context_id = c.id
GROUP BY c.id
ORDER BY c.name COLLATE NOCASE ASC
`).all();
return rows.map(mapContext);
}
function getContextById(id) {
const db = getDb();
const row = db.prepare(`
SELECT c.id, c.name, c.description, c.icon, COUNT(n.id) AS count
FROM contexts c
LEFT JOIN nodes n ON n.context_id = c.id
WHERE c.id = ?
GROUP BY c.id
`).get(id);
return mapContext(row);
}
function getContextByName(name) {
const trimmed = typeof name === 'string' ? name.trim() : '';
if (!trimmed) return null;
const db = getDb();
const row = db.prepare(`
SELECT c.id, c.name, c.description, c.icon, COUNT(n.id) AS count
FROM contexts c
LEFT JOIN nodes n ON n.context_id = c.id
WHERE lower(c.name) = lower(?)
GROUP BY c.id
`).get(trimmed);
return mapContext(row);
}
function createContext({ name, description = null, icon = null }) {
const db = getDb();
const trimmedName = typeof name === 'string' ? name.trim() : '';
if (!trimmedName) {
throw new Error('Context name is required.');
}
const existingCount = Number(db.prepare('SELECT COUNT(*) AS count FROM contexts').get()?.count ?? 0);
if (existingCount >= MAX_CONTEXTS_PER_ACCOUNT) {
throw new Error(`Context limit reached. Maximum ${MAX_CONTEXTS_PER_ACCOUNT} contexts are allowed per account.`);
}
const now = new Date().toISOString();
const info = db.prepare(`
INSERT INTO contexts (name, description, icon, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
`).run(trimmedName, description ?? null, icon ?? null, now, now);
return getContextById(Number(info.lastInsertRowid));
}
function updateContext({ id, name, description, icon }) {
const db = getDb();
const existing = getContextById(id);
if (!existing) {
throw new Error(`Context ${id} not found.`);
}
const nextName = typeof name === 'string' && name.trim() ? name.trim() : existing.name;
const nextDescription = description === undefined ? existing.description : description;
const nextIcon = icon === undefined ? existing.icon : icon;
const now = new Date().toISOString();
db.prepare(`
UPDATE contexts
SET name = ?, description = ?, icon = ?, updated_at = ?
WHERE id = ?
`).run(nextName, nextDescription ?? null, nextIcon ?? null, now, id);
return getContextById(id);
}
function resolveContextId(input = {}) {
const hasContextId = Object.prototype.hasOwnProperty.call(input, 'context_id');
const hasContextName = typeof input.context_name === 'string' && input.context_name.trim().length > 0;
if (!hasContextId && !hasContextName) {
return undefined;
}
if (hasContextId && input.context_id === null) {
if (hasContextName) {
throw new Error('context_name cannot be combined with context_id: null.');
}
return null;
}
let resolvedById = null;
if (hasContextId) {
resolvedById = getContextById(input.context_id);
if (!resolvedById) {
throw new Error(`Context ${input.context_id} not found.`);
}
}
if (!hasContextName) {
return resolvedById ? resolvedById.id : undefined;
}
const byName = getContextByName(input.context_name);
if (!byName) {
throw new Error(`Context "${input.context_name}" not found.`);
}
if (resolvedById && resolvedById.id !== byName.id) {
throw new Error('context_id and context_name refer to different contexts.');
}
return byName.id;
}
module.exports = {
MAX_CONTEXTS_PER_ACCOUNT,
listContexts,
getContextById,
getContextByName,
createContext,
updateContext,
resolveContextId,
};
@@ -1,7 +1,6 @@
'use strict';
const nodeService = require('./nodeService');
const contextService = require('./contextService');
const SEARCH_STOP_WORDS = new Set([
'a', 'about', 'added', 'already', 'an', 'and', 'are', 'as', 'at', 'be', 'by',
@@ -109,41 +108,6 @@ function scoreNodeSearchMatch(node, query) {
return score;
}
function normalizeContextName(value) {
if (typeof value !== 'string') return undefined;
const normalized = value.trim().replace(/\s+/g, ' ');
return normalized || undefined;
}
function resolveSearchContext({ context_name, contextId }) {
const normalizedName = normalizeContextName(context_name);
if (normalizedName) {
const context = contextService.getContextByName(normalizedName);
if (!context) {
console.warn(`directNodeLookupService received unknown context_name "${normalizedName}"; ignoring context filter.`);
return {};
}
return {
contextId: context.id,
context_name: context.name,
};
}
if (typeof contextId === 'number') {
const context = contextService.getContextById(contextId);
if (!context) {
console.warn(`directNodeLookupService received invalid legacy contextId ${contextId}; ignoring context filter.`);
return {};
}
return {
contextId: context.id,
context_name: context.name,
};
}
return {};
}
function hasStrongAnchorMatch(nodes, searchTerm) {
if (!searchTerm || nodes.length === 0) return false;
const highSignalTerms = getHighSignalSearchTerms(searchTerm);
@@ -168,12 +132,9 @@ function directNodeLookup(input = {}) {
};
}
const resolvedContext = resolveSearchContext(input);
let safeNodes = nodeService.getNodes({
search: searchTerm || undefined,
limit,
contextId: resolvedContext.contextId,
createdAfter: input.createdAfter,
createdBefore: input.createdBefore,
eventAfter: input.eventAfter,
@@ -181,7 +142,6 @@ function directNodeLookup(input = {}) {
});
const hadExtraFilters = Boolean(
resolvedContext.contextId !== undefined ||
input.createdAfter ||
input.createdBefore ||
input.eventAfter ||
@@ -209,7 +169,6 @@ function directNodeLookup(input = {}) {
filtersApplied: {
search: searchTerm || undefined,
limit,
context_name: resolvedContext.context_name,
createdAfter: input.createdAfter,
createdBefore: input.createdBefore,
eventAfter: input.eventAfter,
@@ -1,7 +1,6 @@
'use strict';
const { query, transaction, getDb } = require('./sqlite-client');
const contextService = require('./contextService');
function parseMetadata(metadata) {
if (!metadata) return {};
@@ -54,8 +53,6 @@ function mapNodeRow(row) {
return {
...row,
metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null,
context: row.context_json ? JSON.parse(row.context_json) : null,
context_json: undefined,
};
}
@@ -63,17 +60,16 @@ function mapNodeRow(row) {
* Get nodes with optional filtering.
*/
function getNodes(filters = {}) {
const { search, limit = 100, offset = 0, contextId } = 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, n.context_id,
CASE
WHEN c.id IS NULL THEN NULL
ELSE json_object('id', c.id, 'name', c.name, 'description', c.description, 'icon', c.icon)
END as context_json
n.created_at, n.updated_at
FROM nodes n
LEFT JOIN contexts c ON c.id = n.context_id
WHERE 1=1
`;
const params = [];
@@ -83,11 +79,6 @@ function getNodes(filters = {}) {
sql += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.source LIKE ? COLLATE NOCASE)`;
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
}
if (contextId !== undefined) {
sql += ' AND n.context_id = ?';
params.push(contextId);
}
// Sort by search relevance or updated_at
if (search) {
sql += ` ORDER BY
@@ -130,13 +121,8 @@ function searchNodes(filters = {}) {
function getNodeById(id) {
const sql = `
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.created_at, n.updated_at, n.context_id,
CASE
WHEN c.id IS NULL THEN NULL
ELSE json_object('id', c.id, 'name', c.name, 'description', c.description, 'icon', c.icon)
END as context_json
n.created_at, n.updated_at
FROM nodes n
LEFT JOIN contexts c ON c.id = n.context_id
WHERE n.id = ?
`;
@@ -172,8 +158,7 @@ function createNode(nodeData) {
source,
link,
event_date,
metadata = {},
context_id
metadata = {}
} = nodeData;
const title = sanitizeTitle(rawTitle);
@@ -183,13 +168,12 @@ function createNode(nodeData) {
const db = getDb();
const sourceToStore = source ?? ([title, description].filter(Boolean).join('\n\n').trim() || null);
const effectiveContextId = context_id ?? null;
const chunkStatus = getChunkStatusForSource(sourceToStore);
const nodeId = transaction(() => {
const stmt = db.prepare(`
INSERT INTO nodes (title, description, source, link, event_date, metadata, chunk_status, context_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO nodes (title, description, source, link, event_date, metadata, chunk_status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
@@ -200,7 +184,6 @@ function createNode(nodeData) {
event_date ?? null,
JSON.stringify(canonicalMetadata),
chunkStatus,
effectiveContextId ?? null,
now,
now
);
@@ -259,10 +242,6 @@ function updateNode(id, updates, options = {}) {
setFields.push('chunk_status = ?');
params.push(getChunkStatusForSource(normalizedSource));
}
if (Object.prototype.hasOwnProperty.call(updates, 'context_id')) {
setFields.push('context_id = ?');
params.push(updates.context_id ?? null);
}
if (mergedMetadata !== undefined) {
setFields.push('metadata = ?');
params.push(JSON.stringify(mergedMetadata));
@@ -304,7 +283,7 @@ function getNodeCount() {
/**
* Get knowledge graph context overview.
* Returns stats, contexts, hub nodes, and recent activity.
* Returns stats, hub nodes, and recent activity.
*/
function getContext() {
const nodeCount = query('SELECT COUNT(*) as count FROM nodes')[0].count;
@@ -323,12 +302,11 @@ function getContext() {
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
LIMIT 10
`);
return {
stats: { nodeCount, edgeCount, dimensionCount: 0, contextCount: contextService.listContexts().length },
contexts: contextService.listContexts(),
stats: { nodeCount, edgeCount, dimensionCount: 0 },
recentNodes,
hubNodes
};
@@ -3,7 +3,6 @@
const { getDb, query } = require('./sqlite-client');
const nodeService = require('./nodeService');
const edgeService = require('./edgeService');
const contextService = require('./contextService');
const LOW_SIGNAL_PATTERNS = [
/^(yes|yeah|yep|no|nope|nah|ok|okay|cool|great|nice|thanks|thank you|sure|sounds good|go ahead|do it)[.!]?$/i,
@@ -475,7 +474,6 @@ function searchChunks(queryText, nodeIds, limit) {
function retrieveQueryContext(input = {}) {
const queryText = normalizeWhitespace(input.query || '');
const focusedNodeId = input.focused_node_id ?? null;
const requestedActiveContextId = input.active_context_id ?? null;
const limit = Math.min(Math.max(input.limit || 6, 1), 12);
const shouldRetrieve = shouldRetrieveForQuery(queryText);
@@ -486,14 +484,11 @@ function retrieveQueryContext(input = {}) {
mode: 'skip',
reason: 'Query is too lightweight or conversational to justify retrieval.',
focused_node_id: focusedNodeId,
active_context_id: requestedActiveContextId,
nodes: [],
chunks: [],
};
}
const activeContext = requestedActiveContextId ? contextService.getContextById(requestedActiveContextId) : null;
const activeContextId = activeContext ? activeContext.id : null;
const focusedRequest = isFocusedSourceRequest(queryText);
const directNodeRetrieval = isDirectNodeRetrievalQuery(queryText);
const nodesById = new Map();
@@ -527,19 +522,6 @@ function retrieveQueryContext(input = {}) {
});
});
if (activeContextId && !strongRecallMatch) {
const contextMatches = queryText
? nodeService.getNodes({ search: queryText, contextId: activeContextId, limit: Math.max(limit, 4) })
: [];
contextMatches.forEach((node, index) => {
addNodeWithReason(nodesById, node, {
kind: 'context_hint',
reason: 'Also matched inside the active context.',
searchRank: directQueryMatches.length + index,
});
});
}
if (!strongRecallMatch) {
const rankedSeedNodes = rankRetrievedNodes(Array.from(nodesById.values())).slice(0, Math.max(3, limit));
rankedSeedNodes.slice(0, 3).forEach((seed) => {
@@ -583,7 +565,6 @@ function retrieveQueryContext(input = {}) {
? 'Direct node retrieval query: search the graph directly first and broaden only if needed.'
: 'Substantive query: search the graph directly, then pull additional supporting context if helpful.',
focused_node_id: focusedNodeId,
active_context_id: activeContextId,
nodes: finalNodes,
chunks,
};
@@ -32,7 +32,7 @@ function getExistingColumnNames(db, tableName) {
}
function validateExistingRahSchema(db) {
const requiredTables = ['contexts', 'nodes', 'edges', 'chunks'];
const requiredTables = ['nodes', 'edges', 'chunks'];
const missingTables = requiredTables.filter(
tableName => !db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?").get(tableName)
);
@@ -46,16 +46,14 @@ function validateExistingRahSchema(db) {
const requiredNodeColumns = [
'title', 'description', 'source', 'link', 'event_date', 'metadata',
'embedding', 'embedding_updated_at', 'embedding_text', 'chunk_status', 'context_id',
'embedding', 'embedding_updated_at', 'embedding_text', 'chunk_status',
'created_at', 'updated_at'
];
const requiredContextColumns = ['name', 'description', 'icon', 'created_at', 'updated_at'];
const requiredEdgeColumns = ['from_node_id', 'to_node_id', 'source', 'created_at', 'context', 'explanation'];
const requiredChunkColumns = ['node_id', 'chunk_idx', 'text', 'embedding_type', 'metadata', 'created_at'];
const schemaChecks = [
['nodes', requiredNodeColumns],
['contexts', requiredContextColumns],
['edges', requiredEdgeColumns],
['chunks', requiredChunkColumns],
];
@@ -104,15 +102,6 @@ function initDatabase() {
function ensureCoreSchema(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS contexts (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL,
icon TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY,
title TEXT,
@@ -126,13 +115,8 @@ function ensureCoreSchema(db) {
embedding BLOB,
embedding_updated_at TEXT,
embedding_text TEXT,
chunk_status TEXT DEFAULT 'not_chunked',
context_id INTEGER,
FOREIGN KEY (context_id) REFERENCES contexts(id) ON DELETE SET NULL
chunk_status TEXT DEFAULT 'not_chunked'
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_contexts_name_normalized
ON contexts(LOWER(TRIM(name)));
`);
ensureEdgesTableSchema(db);