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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user