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
+7 -11
View File
@@ -15,7 +15,6 @@ export interface QuickAddInput {
rawInput: string;
mode?: QuickAddMode;
description?: string;
contextId?: number | null;
}
export interface QuickAddResult {
@@ -200,7 +199,7 @@ function isCreateNodeResponse(value: unknown): value is CreateNodeResponse {
return true;
}
async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: string, task: string, contextId?: number | null): Promise<string> {
async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: string, task: string): Promise<string> {
const { toolName, execute } = EXTRACTION_TOOL_MAP[type];
if (!execute) {
throw new Error(`Tool ${toolName} does not have an execute function`);
@@ -269,7 +268,6 @@ async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: strin
refined_at: capturedAt,
},
},
context_id: contextId,
}),
});
@@ -296,7 +294,7 @@ async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: strin
}
}
async function handleNoteQuickAdd(rawInput: string, task: string, userDescription?: string, contextId?: number | null): Promise<string> {
async function handleNoteQuickAdd(rawInput: string, task: string, userDescription?: string): Promise<string> {
const content = rawInput.trim();
if (!content) {
throw new Error('Input is required to create a note');
@@ -307,7 +305,6 @@ async function handleNoteQuickAdd(rawInput: string, task: string, userDescriptio
const nodePayload: Record<string, unknown> = {
title,
source: content,
context_id: contextId,
metadata: {
type: 'note',
state: 'not_processed',
@@ -357,7 +354,7 @@ async function handleNoteQuickAdd(rawInput: string, task: string, userDescriptio
});
}
async function handleChatTranscriptQuickAdd(rawInput: string, task: string, contextId?: number | null): Promise<string> {
async function handleChatTranscriptQuickAdd(rawInput: string, task: string): Promise<string> {
const transcript = rawInput.trim();
if (!transcript) {
throw new Error('Input is required to import a chat transcript');
@@ -424,7 +421,6 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string, cont
title,
description: nodeDescription,
source: transcript,
context_id: contextId,
metadata,
}),
});
@@ -451,7 +447,7 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string, cont
});
}
export async function enqueueQuickAdd({ rawInput, mode, description, contextId }: QuickAddInput): Promise<QuickAddResult> {
export async function enqueueQuickAdd({ rawInput, mode, description }: QuickAddInput): Promise<QuickAddResult> {
const inputType = detectInputType(rawInput, mode);
const task = buildTaskPrompt(inputType, rawInput);
const id = `qa_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
@@ -468,11 +464,11 @@ export async function enqueueQuickAdd({ rawInput, mode, description, contextId }
try {
let summary: string;
if (inputType === 'note') {
summary = await handleNoteQuickAdd(rawInput, task, description, contextId);
summary = await handleNoteQuickAdd(rawInput, task, description);
} else if (inputType === 'chat') {
summary = await handleChatTranscriptQuickAdd(rawInput, task, contextId);
summary = await handleChatTranscriptQuickAdd(rawInput, task);
} else {
summary = await handleExtractionQuickAdd(inputType as ExtractionQuickAddType, rawInput, task, contextId);
summary = await handleExtractionQuickAdd(inputType as ExtractionQuickAddType, rawInput, task);
}
console.log(`[QuickAdd] Completed: ${task}`);
-7
View File
@@ -123,13 +123,6 @@ export function summarizeToolExecution(toolName: string, args: any, result: any)
return 'No edges found.';
}
if (toolName === 'writeContext') {
const formatted = ensureString(result.data?.formatted_display);
if (formatted) {
return `Saved context as ${formatted}.`;
}
}
if (result.data?.formatted_display) {
return ensureString(result.data.formatted_display) || fallback;
}
+24
View File
@@ -0,0 +1,24 @@
import type { NextRequest } from 'next/server';
export function extractBearerToken(headerValue: string | null | undefined): string | null {
if (!headerValue) return null;
const parts = headerValue.split(' ');
if (parts.length !== 2 || !/^Bearer$/i.test(parts[0] || '')) {
return null;
}
return parts[1] || null;
}
export function getCurrentSupabaseToken(): string | null {
return null;
}
export function getInternalAuthHeaders(
headers: Record<string, string> = {}
): Record<string, string> {
return { ...headers };
}
export function applyRequestSupabaseAuth(_request: NextRequest): () => void {
return () => {};
}
+5 -139
View File
@@ -8,23 +8,6 @@ export interface AutoContextSummary {
edgeCount: number;
}
export interface ContextAnchorSummary {
id: number;
title: string;
description: string;
updatedAt: string;
edgeCount: number;
}
export interface PromptContextSummary {
id: number;
name: string;
description: string | null;
icon: string | null;
count: number;
anchor: ContextAnchorSummary | null;
}
function truncate(value: string | null | undefined, maxChars: number): string {
const trimmed = (value || '').trim();
if (trimmed.length <= maxChars) return trimmed;
@@ -67,128 +50,11 @@ function fetchAutoContextRows(limit: number): AutoContextSummary[] {
}));
}
export function getHubNodes(limit = 5): AutoContextSummary[] {
export function getHubNodes(limit = 10): AutoContextSummary[] {
return fetchAutoContextRows(limit);
}
export function getContextSummaries(limit = 12): PromptContextSummary[] {
const db = getSQLiteClient();
const rows = db.query<{
id: number;
name: string;
description: string | null;
icon: string | null;
count: number;
anchor_id: number | null;
anchor_title: string | null;
anchor_description: string | null;
anchor_updated_at: string | null;
anchor_edge_count: number | null;
}>(`
WITH context_counts AS (
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
),
ranked_anchors AS (
SELECT
c.id AS context_id,
n.id AS node_id,
n.title,
n.description,
n.updated_at,
COUNT(e.id) AS edge_count,
ROW_NUMBER() OVER (
PARTITION BY c.id
ORDER BY COUNT(e.id) DESC, n.updated_at DESC, n.id ASC
) AS anchor_rank
FROM contexts c
LEFT JOIN nodes n ON n.context_id = c.id
LEFT JOIN edges e ON (e.from_node_id = n.id OR e.to_node_id = n.id)
GROUP BY c.id, n.id
)
SELECT
cc.id,
cc.name,
cc.description,
cc.icon,
cc.count,
ra.node_id AS anchor_id,
ra.title AS anchor_title,
ra.description AS anchor_description,
ra.updated_at AS anchor_updated_at,
ra.edge_count AS anchor_edge_count
FROM context_counts cc
LEFT JOIN ranked_anchors ra
ON ra.context_id = cc.id
AND ra.anchor_rank = 1
ORDER BY cc.name COLLATE NOCASE ASC
LIMIT ?
`, [limit]).rows;
return rows.map((row) => ({
id: row.id,
name: row.name,
description: row.description ?? null,
icon: row.icon ?? null,
count: Number(row.count ?? 0),
anchor: row.anchor_id == null ? null : {
id: Number(row.anchor_id),
title: row.anchor_title || 'Untitled node',
description: row.anchor_description || '',
updatedAt: row.anchor_updated_at || '',
edgeCount: Number(row.anchor_edge_count ?? 0),
},
}));
}
export function buildContextsBlock(limit = 12): string | null {
const contexts = getContextSummaries(limit);
if (contexts.length === 0) {
return null;
}
const lines: string[] = [
'User Contexts',
'Contexts are optional soft hints. Use them when they are explicit and useful, but rely primarily on title, description, source, edges, and recency.',
'',
];
contexts.forEach((context, index) => {
const description = truncate(context.description, 140) || 'No description.';
const iconPrefix = context.icon ? `${context.icon} ` : '';
lines.push(`${index + 1}. ${iconPrefix}${context.name} (${context.count} nodes)`);
lines.push(` ${description}`);
});
return lines.join('\n');
}
export function buildContextAnchorsBlock(limit = 12): string | null {
const contexts = getContextSummaries(limit).filter((context) => context.anchor);
if (contexts.length === 0) {
return null;
}
const lines: string[] = [
'Context Anchors',
'Each context anchor is the highest-edge node in that context. Use it only as an optional waypoint when that context is already clearly relevant.',
'',
];
contexts.forEach((context, index) => {
const anchor = context.anchor!;
lines.push(`${index + 1}. ${context.name}: [NODE:${anchor.id}:"${anchor.title}"] (${anchor.edgeCount} edges)`);
if (anchor.description) {
lines.push(` ${truncate(anchor.description, 160)}`);
}
});
return lines.join('\n');
}
export function buildHubNodesBlock(limit = 5): string | null {
export function buildHubNodesBlock(limit = 10): string | null {
const summaries = getHubNodes(limit);
if (summaries.length === 0) {
return null;
@@ -210,10 +76,10 @@ export function buildHubNodesBlock(limit = 5): string | null {
return lines.join('\n');
}
export function getAutoContextSummaries(limit = 5): AutoContextSummary[] {
export function getAutoContextSummaries(limit = 10): AutoContextSummary[] {
return getHubNodes(limit);
}
export function buildAutoContextBlock(limit = 5): string | null {
return buildContextsBlock(limit);
export function buildAutoContextBlock(limit = 10): string | null {
return buildHubNodesBlock(limit);
}
-1
View File
@@ -463,7 +463,6 @@ export class ChunkService {
return result.rows;
} catch (error) {
sqlite.disableFtsTable('chunks', 'chunks_fts query failed during chunk search', error);
console.warn('[ChunkSearch] FTS chunk search failed, falling back to LIKE:', error);
return [];
}
-237
View File
@@ -1,237 +0,0 @@
import { getSQLiteClient } from './sqlite-client';
import type { Context, ContextSummary, Node } from '@/types/database';
import { nodeService } from './nodes';
type ContextRow = Context;
export const MAX_CONTEXTS_PER_ACCOUNT = 10;
function normalizeContextName(name: string): string {
return name.trim().replace(/\s+/g, ' ');
}
function assertContextName(name: unknown): string {
if (typeof name !== 'string') {
throw new Error('Context name is required.');
}
const normalized = normalizeContextName(name);
if (!normalized) {
throw new Error('Context name is required.');
}
return normalized;
}
function assertContextDescription(description: unknown): string {
if (typeof description !== 'string') {
throw new Error('Context description is required.');
}
const normalized = description.trim();
if (!normalized) {
throw new Error('Context description is required.');
}
return normalized;
}
function mapContextRow(row: ContextRow): Context {
return {
id: Number(row.id),
name: row.name,
description: row.description ?? null,
icon: row.icon ?? null,
created_at: row.created_at,
updated_at: row.updated_at,
};
}
export class ContextService {
async listContexts(): Promise<ContextSummary[]> {
const sqlite = getSQLiteClient();
const rows = sqlite.query<ContextSummary>(`
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
`).rows;
return rows.map((row) => ({
id: Number(row.id),
name: row.name,
description: row.description ?? null,
icon: row.icon ?? null,
count: Number(row.count ?? 0),
}));
}
async getContextById(id: number): Promise<ContextSummary | null> {
const sqlite = getSQLiteClient();
const row = sqlite.query<ContextSummary>(`
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
`, [id]).rows[0];
if (!row) return null;
return {
id: Number(row.id),
name: row.name,
description: row.description ?? null,
icon: row.icon ?? null,
count: Number(row.count ?? 0),
};
}
async getContextByName(name: string): Promise<Context | null> {
const normalized = assertContextName(name);
const sqlite = getSQLiteClient();
const row = sqlite.query<ContextRow>(`
SELECT id, name, description, icon, created_at, updated_at
FROM contexts
WHERE LOWER(TRIM(name)) = LOWER(TRIM(?))
LIMIT 1
`, [normalized]).rows[0];
return row ? mapContextRow(row) : null;
}
async createContext(input: { name: string; description: string; icon?: string | null }): Promise<Context> {
const name = assertContextName(input.name);
const description = assertContextDescription(input.description);
const icon = typeof input.icon === 'string' && input.icon.trim() ? input.icon.trim() : null;
const sqlite = getSQLiteClient();
const now = new Date().toISOString();
const existing = await this.getContextByName(name);
if (existing) {
throw new Error(`Context "${name}" already exists.`);
}
const contextCountRow = sqlite.query<{ count: number }>(`
SELECT COUNT(*) AS count
FROM contexts
`).rows[0];
const existingCount = Number(contextCountRow?.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 result = sqlite.prepare(`
INSERT INTO contexts (name, description, icon, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
`).run(name, description, icon, now, now);
const created = await this.getContextById(Number(result.lastInsertRowid));
if (!created) {
throw new Error('Failed to create context.');
}
return {
...created,
created_at: now,
updated_at: now,
};
}
async updateContext(input: { id: number; name?: string; description?: string; icon?: string | null }): Promise<Context> {
const sqlite = getSQLiteClient();
const existing = sqlite.query<ContextRow>(`
SELECT id, name, description, icon, created_at, updated_at
FROM contexts
WHERE id = ?
`, [input.id]).rows[0];
if (!existing) {
throw new Error(`Context ${input.id} not found.`);
}
const nextName = input.name !== undefined ? assertContextName(input.name) : existing.name;
const nextDescription = input.description !== undefined
? assertContextDescription(input.description)
: existing.description;
const nextIcon = input.icon !== undefined
? (typeof input.icon === 'string' && input.icon.trim() ? input.icon.trim() : null)
: existing.icon;
const conflicting = sqlite.query<{ id: number }>(`
SELECT id FROM contexts
WHERE LOWER(TRIM(name)) = LOWER(TRIM(?))
AND id != ?
LIMIT 1
`, [nextName, input.id]).rows[0];
if (conflicting) {
throw new Error(`Context "${nextName}" already exists.`);
}
const now = new Date().toISOString();
sqlite.prepare(`
UPDATE contexts
SET name = ?, description = ?, icon = ?, updated_at = ?
WHERE id = ?
`).run(nextName, nextDescription, nextIcon, now, input.id);
return {
id: input.id,
name: nextName,
description: nextDescription ?? null,
icon: nextIcon ?? null,
created_at: existing.created_at,
updated_at: now,
};
}
async getNodesForContext(id: number): Promise<Node[]> {
return nodeService.getNodes({ contextId: id, limit: 500 });
}
async resolveContextId(input: { context_id?: number | null; context_name?: string | null }): Promise<number | null | undefined> {
const hasContextId =
Object.prototype.hasOwnProperty.call(input, 'context_id') &&
input.context_id !== undefined;
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: Context | null = null;
if (hasContextId) {
if (typeof input.context_id !== 'number' || !Number.isInteger(input.context_id) || input.context_id <= 0) {
throw new Error('context_id must be a positive integer or null.');
}
const byId = await this.getContextById(input.context_id);
if (!byId) {
throw new Error(`Context ${input.context_id} not found.`);
}
resolvedById = {
...byId,
created_at: '',
updated_at: '',
};
}
if (hasContextName) {
const byName = await this.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;
}
return resolvedById?.id;
}
}
export const contextService = new ContextService();
-1
View File
@@ -4,7 +4,6 @@ import type { DatabaseIntegrityReport } from './sqlite-client';
export { nodeService, NodeService } from './nodes';
export { chunkService, ChunkService } from './chunks';
export { edgeService, EdgeService } from './edges';
export { contextService, ContextService } from './contextService';
// export { HelperService } from './helpers'; // Removed - migrated to JSON-based service
// Types
+26 -95
View File
@@ -2,12 +2,10 @@ import { getSQLiteClient } from './sqlite-client';
import { Node, NodeFilters } from '@/types/database';
import { eventBroadcaster } from '../events';
import { EmbeddingService } from '@/services/embeddings';
import { scoreNodeSearchMatch } from './searchRanking';
import { getHighSignalSearchTerms, scoreNodeSearchMatch } from './searchRanking';
import { buildCanonicalNodeMetadata, mergeNodeMetadata } from '@/services/nodes/metadata';
type NodeRow = Node & {
context_json: string | null;
};
type NodeRow = Node;
type NodeSearchRow = NodeRow & { rank?: number; similarity?: number };
function sanitizeFtsQuery(input: string): string {
@@ -21,35 +19,7 @@ function sanitizeFtsQuery(input: string): string {
}
function extractRelaxedSearchTerms(query: string): string[] {
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 = query
.toLowerCase()
.replace(/[^a-z0-9\s]+/g, ' ')
.split(/\s+/)
.map(term => term.trim())
.filter(Boolean);
const expanded = new Set<string>();
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);
return getHighSignalSearchTerms(query).slice(0, 8);
}
function reciprocalRankFuse<T extends { id: number }>(
@@ -90,7 +60,6 @@ export class NodeService {
eventAfter,
eventBefore,
chunkStatus,
contextId,
} = filters;
if (search?.trim()) {
@@ -112,8 +81,6 @@ export class NodeService {
if (eventAfter) { query += ` AND n.event_date >= ?`; params.push(eventAfter); }
if (eventBefore) { query += ` AND n.event_date < ?`; params.push(eventBefore); }
if (chunkStatus) { query += ` AND n.chunk_status = ?`; params.push(chunkStatus); }
if (contextId !== undefined) { query += ` AND n.context_id = ?`; params.push(contextId); }
const result = sqlite.query<{ total: number }>(query, params);
return result.rows[0]?.total ?? 0;
}
@@ -131,7 +98,6 @@ export class NodeService {
eventAfter,
eventBefore,
chunkStatus,
contextId,
} = filters;
if (search?.trim()) {
@@ -143,14 +109,9 @@ export class NodeService {
let query = `
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.chunk_status, n.embedding_updated_at, n.embedding_text,
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,
(SELECT COUNT(*) FROM edges WHERE from_node_id = n.id OR to_node_id = n.id) as edge_count
FROM nodes n
LEFT JOIN contexts c ON c.id = n.context_id
WHERE 1=1
`;
const params: any[] = [];
@@ -182,11 +143,6 @@ export class NodeService {
query += ` AND n.chunk_status = ?`;
params.push(chunkStatus);
}
if (contextId !== undefined) {
query += ` AND n.context_id = ?`;
params.push(contextId);
}
// Sorting logic
if (search) {
// For search queries, prioritize by relevance: exact title → starts with → contains in title → description → source
@@ -243,13 +199,8 @@ export class NodeService {
const query = `
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.chunk_status, n.embedding_updated_at, n.embedding_text,
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 = ?
`;
const result = sqlite.query<NodeRow>(query, [id]);
@@ -275,7 +226,6 @@ export class NodeService {
event_date,
chunk_status,
metadata = {},
context_id,
} = nodeData;
const canonicalMetadata = buildCanonicalNodeMetadata({ metadata });
const now = new Date().toISOString();
@@ -284,8 +234,8 @@ export class NodeService {
const nodeId = sqlite.transaction(() => {
// Insert node using prepare/run for lastInsertRowid access
const nodeResult = sqlite.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 (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
title,
description ?? null,
@@ -294,7 +244,6 @@ export class NodeService {
event_date ?? null,
JSON.stringify(canonicalMetadata),
chunk_status ?? null,
context_id ?? null,
now,
now
);
@@ -353,10 +302,6 @@ export class NodeService {
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 (Object.prototype.hasOwnProperty.call(updates, 'context_id')) {
setFields.push('context_id = ?');
params.push(updates.context_id ?? null);
}
if (Object.prototype.hasOwnProperty.call(updates, 'chunk_status')) {
setFields.push('chunk_status = ?');
params.push(updates.chunk_status ?? null);
@@ -419,11 +364,9 @@ export class NodeService {
}
private mapNodeRow(row: NodeRow): Node {
const { context_json, ...baseRow } = row;
return {
...baseRow,
metadata: baseRow.metadata ? (typeof baseRow.metadata === 'string' ? JSON.parse(baseRow.metadata) : baseRow.metadata) : null,
context: context_json ? JSON.parse(context_json) : null,
...row,
metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null,
};
}
@@ -433,7 +376,6 @@ export class NodeService {
createdBefore,
eventAfter,
eventBefore,
contextId,
} = filters;
const clauses: string[] = [];
@@ -443,8 +385,6 @@ export class NodeService {
if (createdBefore) { clauses.push(`${alias}.created_at < ?`); params.push(createdBefore); }
if (eventAfter) { clauses.push(`${alias}.event_date >= ?`); params.push(eventAfter); }
if (eventBefore) { clauses.push(`${alias}.event_date < ?`); params.push(eventBefore); }
if (contextId !== undefined) { clauses.push(`${alias}.context_id = ?`); params.push(contextId); }
return { clauses, params };
}
@@ -517,7 +457,8 @@ export class NodeService {
return Number(result.rows[0]?.total ?? 0);
} catch (error) {
sqlite.disableFtsTable('nodes', 'nodes_fts query failed during count search', error);
sqlite.getIntegrityReport(true);
console.warn('[NodeSearch] FTS count failed, falling back to LIKE count:', error);
}
}
@@ -546,6 +487,7 @@ export class NodeService {
): NodeSearchRow[] {
const ftsQuery = sanitizeFtsQuery(search);
if (!ftsQuery) return [];
if (!sqlite.canUseFtsTable('nodes')) return [];
const { clauses, params } = this.buildNodeFilterClauses(filters);
@@ -561,15 +503,10 @@ export class NodeService {
)
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.chunk_status, n.embedding_updated_at, n.embedding_text,
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,
fm.rank
FROM fts_matches fm
JOIN nodes n ON n.id = fm.rowid
LEFT JOIN contexts c ON c.id = n.context_id
${whereClauses}
ORDER BY fm.rank
LIMIT ?
@@ -577,7 +514,8 @@ export class NodeService {
return result.rows;
} catch (error) {
sqlite.disableFtsTable('nodes', 'nodes_fts query failed during node search', error);
sqlite.getIntegrityReport(true);
console.warn('[NodeSearch] FTS search failed, falling back to LIKE:', error);
return [];
}
}
@@ -593,13 +531,8 @@ export class NodeService {
let query = `
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.chunk_status, n.embedding_updated_at, n.embedding_text,
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 queryParams = [...params];
@@ -641,13 +574,8 @@ export class NodeService {
let query = `
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.chunk_status, n.embedding_updated_at, n.embedding_text,
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 queryParams = [...params];
@@ -718,15 +646,10 @@ export class NodeService {
)
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.chunk_status, n.embedding_updated_at, n.embedding_text,
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,
(1.0 / (1.0 + vm.distance)) AS similarity
FROM vector_matches vm
JOIN nodes n ON n.id = vm.node_id
LEFT JOIN contexts c ON c.id = n.context_id
${whereClauses}
ORDER BY vm.distance
LIMIT ?
@@ -769,6 +692,14 @@ export class NodeService {
return updatedNodes;
}
async getAllDimensions(): Promise<string[]> {
return [];
}
async getDimensionStats(): Promise<{dimension: string, count: number}[]> {
return [];
}
}
// Export singleton instance
File diff suppressed because it is too large Load Diff
+1
View File
@@ -16,6 +16,7 @@ export interface DatabaseEvent {
| 'AGENT_DELEGATION_CREATED'
| 'AGENT_DELEGATION_UPDATED'
| 'GUIDE_UPDATED'
| 'SKILL_UPDATED'
| 'QUICK_ADD_COMPLETED'
| 'QUICK_ADD_FAILED'
| 'CONNECTION_ESTABLISHED';
@@ -1,4 +1,3 @@
import { contextService } from '@/services/database/contextService';
import { nodeService } from '@/services/database/nodes';
import { countHighSignalQueryTermMatches, getHighSignalSearchTerms, scoreNodeSearchMatch } from '@/services/database/searchRanking';
import type { Node } from '@/types/database';
@@ -6,8 +5,6 @@ import type { Node } from '@/types/database';
export interface DirectNodeLookupInput {
search?: string;
limit?: number;
context_name?: string;
contextId?: number;
createdAfter?: string;
createdBefore?: string;
eventAfter?: string;
@@ -20,7 +17,6 @@ export interface DirectNodeLookupResult {
filtersApplied: {
search?: string;
limit: number;
context_name?: string;
createdAfter?: string;
createdBefore?: string;
eventAfter?: string;
@@ -28,41 +24,6 @@ export interface DirectNodeLookupResult {
};
}
function normalizeContextName(value: string | undefined): string | undefined {
if (typeof value !== 'string') return undefined;
const normalized = value.trim().replace(/\s+/g, ' ');
return normalized || undefined;
}
async function resolveSearchContext(input: DirectNodeLookupInput): Promise<{ contextId?: number; context_name?: string }> {
const normalizedName = normalizeContextName(input.context_name);
if (normalizedName) {
const context = await contextService.getContextByName(normalizedName);
if (!context) {
console.warn(`directNodeLookup received unknown context_name "${normalizedName}"; ignoring context filter.`);
return {};
}
return {
contextId: context.id,
context_name: context.name,
};
}
if (typeof input.contextId === 'number') {
const context = await contextService.getContextById(input.contextId);
if (!context) {
console.warn(`directNodeLookup received invalid legacy contextId ${input.contextId}; ignoring context filter.`);
return {};
}
return {
contextId: context.id,
context_name: context.name,
};
}
return {};
}
function hasStrongAnchorMatch(nodes: Node[], searchTerm: string): boolean {
if (!searchTerm || nodes.length === 0) return false;
const highSignalTerms = getHighSignalSearchTerms(searchTerm);
@@ -87,11 +48,9 @@ export async function directNodeLookup(input: DirectNodeLookupInput): Promise<Di
};
}
const resolvedContext = await resolveSearchContext(input);
const effectiveFilters = {
search: searchTerm,
limit,
contextId: resolvedContext.contextId,
searchMode: 'standard' as const,
createdAfter: input.createdAfter,
createdBefore: input.createdBefore,
@@ -102,7 +61,6 @@ export async function directNodeLookup(input: DirectNodeLookupInput): Promise<Di
let safeNodes = await nodeService.getNodes(effectiveFilters);
const hadExtraFilters = Boolean(
effectiveFilters.contextId !== undefined ||
effectiveFilters.createdAfter ||
effectiveFilters.createdBefore ||
effectiveFilters.eventAfter ||
@@ -130,7 +88,6 @@ export async function directNodeLookup(input: DirectNodeLookupInput): Promise<Di
filtersApplied: {
search: searchTerm,
limit,
context_name: resolvedContext.context_name,
createdAfter: input.createdAfter,
createdBefore: input.createdBefore,
eventAfter: input.eventAfter,
+9 -33
View File
@@ -1,5 +1,4 @@
import { chunkService } from '@/services/database/chunks';
import { contextService } from '@/services/database/contextService';
import { edgeService } from '@/services/database/edges';
import { nodeService } from '@/services/database/nodes';
import { countHighSignalQueryTermMatches, scoreNodeSearchMatch } from '@/services/database/searchRanking';
@@ -33,7 +32,6 @@ export interface QueryContextResult {
mode: 'skip' | 'focused' | 'query';
reason: string;
focused_node_id: number | null;
active_context_id: number | null;
nodes: RetrievedContextNode[];
chunks: RetrievedContextChunk[];
}
@@ -41,7 +39,6 @@ export interface QueryContextResult {
export interface RetrieveQueryContextInput {
query: string;
focused_node_id?: number | null;
active_context_id?: number | null;
limit?: number;
}
@@ -104,7 +101,7 @@ function truncateText(value: string | null | undefined, maxLength = 180): string
function queryTermCount(query: string): number {
return normalizeWhitespace(query)
.split(' ')
.map((term) => term.trim())
.map(term => term.trim())
.filter(Boolean)
.length;
}
@@ -161,7 +158,7 @@ function extractHighSignalTerms(query: string): string[] {
.toLowerCase()
.replace(/[^a-z0-9\s]+/g, ' ')
.split(/\s+/)
.map((term) => singularizeTerm(term.trim()))
.map(term => singularizeTerm(term.trim()))
.filter(Boolean);
const seen = new Set<string>();
@@ -184,8 +181,7 @@ function isLikelyUserNoteRecallQuery(query: string): boolean {
const explicitRecall = USER_RECALL_PATTERN.test(normalized);
const firstPersonRecall = FIRST_PERSON_PATTERN.test(normalized) && /\bwhat\b/i.test(normalized);
const explicitLookup = LOOKUP_PATTERN.test(normalized)
&& (FIRST_PERSON_PATTERN.test(normalized) || /\b(created|saved|wrote|added)\b/i.test(normalized));
const explicitLookup = LOOKUP_PATTERN.test(normalized) && (FIRST_PERSON_PATTERN.test(normalized) || /\b(created|saved|wrote|added)\b/i.test(normalized));
const firstPersonShareRecall = FIRST_PERSON_SHARE_PATTERN.test(normalized);
const noteHint = NOTE_HINT_PATTERN.test(normalized);
const recentHint = RECENT_REFERENCE_PATTERN.test(normalized);
@@ -211,8 +207,8 @@ function buildRecallSearchVariants(query: string): string[] {
const terms = extractHighSignalTerms(query);
if (terms.length === 0) return [];
const topicalTerms = terms.filter((term) => !NOTE_TERMS.has(term));
const noteTerms = terms.filter((term) => NOTE_TERMS.has(term));
const topicalTerms = terms.filter(term => !NOTE_TERMS.has(term));
const noteTerms = terms.filter(term => NOTE_TERMS.has(term));
const phraseVariants = extractRecallPhraseVariants(query);
const variants: string[] = [];
@@ -281,8 +277,8 @@ function scoreRecallMatch(node: Node, query: string): number {
if (normalizedSource.includes(normalizedPhrase)) score += 900;
}
const titleTermMatches = terms.filter((term) => normalizedTitle.includes(term)).length;
const totalTermMatches = terms.filter((term) => combined.includes(term)).length;
const titleTermMatches = terms.filter(term => normalizedTitle.includes(term)).length;
const totalTermMatches = terms.filter(term => combined.includes(term)).length;
score += titleTermMatches * 250;
score += totalTermMatches * 120;
@@ -298,7 +294,7 @@ function scoreRecallMatch(node: Node, query: string): number {
}
function hasStrongRecallMatch(nodes: Node[], query: string): boolean {
return nodes.some((node) => scoreRecallMatch(node, query) >= 1800);
return nodes.some(node => scoreRecallMatch(node, query) >= 1800);
}
function isLikelyUserAuthoredNote(node: Node): boolean {
@@ -345,7 +341,7 @@ export function isFocusedSourceRequest(query: string): boolean {
export function shouldRetrieveForQuery(query: string): boolean {
const trimmed = normalizeWhitespace(query);
if (!trimmed) return false;
if (LOW_SIGNAL_PATTERNS.some((pattern) => pattern.test(trimmed))) return false;
if (LOW_SIGNAL_PATTERNS.some(pattern => pattern.test(trimmed))) return false;
if (isFocusedSourceRequest(trimmed)) return true;
if (SOURCE_DETAIL_PATTERN.test(trimmed)) return true;
@@ -413,7 +409,6 @@ function rankRetrievedNodes(nodes: RetrievedContextNode[]): RetrievedContextNode
export async function retrieveQueryContext(input: RetrieveQueryContextInput): Promise<QueryContextResult> {
const query = 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(query);
@@ -424,16 +419,11 @@ export async function retrieveQueryContext(input: RetrieveQueryContextInput): Pr
mode: 'skip',
reason: 'Query is too lightweight or conversational to justify retrieval.',
focused_node_id: focusedNodeId,
active_context_id: requestedActiveContextId,
nodes: [],
chunks: [],
};
}
const activeContextId = requestedActiveContextId
? (await contextService.getContextById(requestedActiveContextId))?.id ?? null
: null;
const focusedRequest = isFocusedSourceRequest(query);
const nodesById = new Map<number, RetrievedContextNode>();
@@ -470,19 +460,6 @@ export async function retrieveQueryContext(input: RetrieveQueryContextInput): Pr
});
});
if (activeContextId && !strongRecallMatch) {
const contextMatches = query
? await nodeService.getNodes({ search: query, 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));
for (const seed of rankedSeedNodes.slice(0, 3)) {
@@ -518,7 +495,6 @@ export async function retrieveQueryContext(input: RetrieveQueryContextInput): Pr
? '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: chunks.map((chunk) => {
const owner = finalNodes.find((node) => node.id === chunk.node_id) || directQueryMatches.find((node) => node.id === chunk.node_id);
+4 -9
View File
@@ -19,7 +19,6 @@ interface NodeRecord {
title: string;
source: string | null;
description: string | null;
context_name: string | null;
embedding?: Buffer | null;
embedding_updated_at?: string | null;
embedding_text?: string | null;
@@ -57,7 +56,6 @@ export class NodeEmbedder {
Title: ${node.title}
Source: ${node.source || 'No source'}
Context: ${node.context_name || 'none'}
Focus on the main concepts, key relationships, and practical implications.`;
@@ -103,7 +101,7 @@ Focus on the main concepts, key relationships, and practical implications.`;
node.title,
node.source || '',
node.description,
node.context_name
null
);
// Add AI analysis if source exists
@@ -173,29 +171,26 @@ Focus on the main concepts, key relationships, and practical implications.`;
if (nodeId) {
// Single node
query = `
SELECT n.id, n.title, n.source, n.description, c.name as context_name,
SELECT n.id, n.title, n.source, n.description,
n.embedding, n.embedding_updated_at
FROM nodes n
LEFT JOIN contexts c ON c.id = n.context_id
WHERE n.id = ?
`;
params = [nodeId];
} else if (forceReEmbed) {
// All nodes
query = `
SELECT n.id, n.title, n.source, n.description, c.name as context_name,
SELECT n.id, n.title, n.source, n.description,
n.embedding, n.embedding_updated_at
FROM nodes n
LEFT JOIN contexts c ON c.id = n.context_id
ORDER BY n.id
`;
} else {
// Only nodes without embeddings
query = `
SELECT n.id, n.title, n.source, n.description, c.name as context_name,
SELECT n.id, n.title, n.source, n.description,
n.embedding, n.embedding_updated_at
FROM nodes n
LEFT JOIN contexts c ON c.id = n.context_id
WHERE n.embedding IS NULL OR n.embedding_updated_at IS NULL
ORDER BY n.id
`;