feat: port holistic node refinement contract

This commit is contained in:
“BeeRad”
2026-04-11 21:37:52 +10:00
parent 35f9ecf89c
commit 3ae46245ec
119 changed files with 6596 additions and 10982 deletions
+2 -14
View File
@@ -12,9 +12,6 @@
import { nodeService, edgeService } from '@/services/database';
import { Node } from '@/types/database';
// Entity-like dimensions where we expect to find referenceable entities
const ENTITY_DIMENSIONS = ['people', 'companies', 'organizations', 'books', 'papers', 'articles', 'podcasts', 'creators'];
/**
* Clean up a candidate entity string by removing common prefixes/suffixes.
*/
@@ -113,7 +110,7 @@ function isGenericPhrase(phrase: string): boolean {
/**
* Look up existing nodes that match candidate entity strings.
* Uses exact title matching (case-insensitive) with optional dimension filtering.
* Uses exact title matching (case-insensitive).
*/
async function findMatchingEntityNodes(candidates: string[]): Promise<Map<string, Node>> {
const matches = new Map<string, Node>();
@@ -131,16 +128,7 @@ async function findMatchingEntityNodes(candidates: string[]): Promise<Map<string
const matchingNode = allNodes.find(node => {
const normalizedTitle = (node.title || '').toLowerCase().trim();
if (normalizedTitle !== normalizedCandidate) return false;
// Optionally filter to entity-like dimensions for higher confidence
// But don't require it - some entities might not have dimensions yet
const nodeDimensions = node.dimensions || [];
const hasEntityDimension = nodeDimensions.some(dim =>
ENTITY_DIMENSIONS.includes(dim.toLowerCase())
);
// Accept if it has an entity dimension OR if it's a short title (likely a named entity)
return hasEntityDimension || node.title.length < 50;
return node.title.length < 80;
});
if (matchingNode) {
+18 -10
View File
@@ -15,6 +15,7 @@ export interface QuickAddInput {
rawInput: string;
mode?: QuickAddMode;
description?: string;
contextId?: number | null;
}
export interface QuickAddResult {
@@ -65,7 +66,7 @@ function buildTaskPrompt(type: QuickAddInputType, input: string): string {
case 'pdf':
return `Quick Add: extract PDF and create node → ${input}`;
case 'note':
return `Quick Add note: create a node from this text with no dimensions${input}`;
return `Quick Add note: create a node from this text with optional context${input}`;
case 'chat':
return `Quick Add: import chat transcript and summarize → ${input.slice(0, 120)}${input.length > 120 ? '…' : ''}`;
}
@@ -199,12 +200,11 @@ function isCreateNodeResponse(value: unknown): value is CreateNodeResponse {
return true;
}
async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: string, task: string): Promise<string> {
async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: string, task: string, contextId?: number | null): Promise<string> {
const { toolName, execute } = EXTRACTION_TOOL_MAP[type];
if (!execute) {
throw new Error(`Tool ${toolName} does not have an execute function`);
}
try {
const rawResult = await execute({ url }, { toolCallId: 'quickadd-extract', messages: [] });
@@ -263,6 +263,7 @@ async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: strin
refined_at: new Date().toISOString(),
},
},
context_id: contextId,
}),
});
@@ -289,7 +290,7 @@ async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: strin
}
}
async function handleNoteQuickAdd(rawInput: string, task: string, userDescription?: string): Promise<string> {
async function handleNoteQuickAdd(rawInput: string, task: string, userDescription?: string, contextId?: number | null): Promise<string> {
const content = rawInput.trim();
if (!content) {
throw new Error('Input is required to create a note');
@@ -299,6 +300,7 @@ 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',
@@ -310,6 +312,7 @@ async function handleNoteQuickAdd(rawInput: string, task: string, userDescriptio
},
};
// If user provided a description, use it instead of auto-generating
if (userDescription && userDescription.trim()) {
nodePayload.description = userDescription.trim();
}
@@ -342,7 +345,7 @@ async function handleNoteQuickAdd(rawInput: string, task: string, userDescriptio
});
}
async function handleChatTranscriptQuickAdd(rawInput: string, task: string): Promise<string> {
async function handleChatTranscriptQuickAdd(rawInput: string, task: string, contextId?: number | null): Promise<string> {
const transcript = rawInput.trim();
if (!transcript) {
throw new Error('Input is required to import a chat transcript');
@@ -401,8 +404,9 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string): Pro
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
source: transcript,
description: nodeDescription,
source: transcript,
context_id: contextId,
metadata,
}),
});
@@ -429,7 +433,7 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string): Pro
});
}
export async function enqueueQuickAdd({ rawInput, mode, description }: QuickAddInput): Promise<QuickAddResult> {
export async function enqueueQuickAdd({ rawInput, mode, description, contextId }: 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)}`;
@@ -441,21 +445,25 @@ export async function enqueueQuickAdd({ rawInput, mode, description }: QuickAddI
status: 'queued',
};
// Run async - fire and forget
setImmediate(async () => {
try {
let summary: string;
if (inputType === 'note') {
await handleNoteQuickAdd(rawInput, task, description);
summary = await handleNoteQuickAdd(rawInput, task, description, contextId);
} else if (inputType === 'chat') {
await handleChatTranscriptQuickAdd(rawInput, task);
summary = await handleChatTranscriptQuickAdd(rawInput, task, contextId);
} else {
await handleExtractionQuickAdd(inputType as ExtractionQuickAddType, rawInput, task);
summary = await handleExtractionQuickAdd(inputType as ExtractionQuickAddType, rawInput, task, contextId);
}
console.log(`[QuickAdd] Completed: ${task}`);
// Broadcast completion so ThreePanelLayout can remove the pending placeholder
eventBroadcaster.broadcast({
type: 'QUICK_ADD_COMPLETED',
data: { quickAddId: id, source: 'quick-add' }
});
// Also broadcast NODE_CREATED to refresh the feed
eventBroadcaster.broadcast({
type: 'NODE_CREATED',
data: { node: { title: task }, source: 'quick-add' }
+29 -43
View File
@@ -1,5 +1,4 @@
import { getSQLiteClient } from '@/services/database/sqlite-client';
import { getAutoContextSettings } from '@/services/settings/autoContextSettings';
export interface AutoContextSummary {
id: number;
@@ -34,28 +33,30 @@ function truncate(value: string | null | undefined, maxChars: number): string {
function fetchAutoContextRows(limit: number): AutoContextSummary[] {
const db = getSQLiteClient();
const rows = db.query<{
id: number;
title: string | null;
description: string | null;
updated_at: string;
edge_count: number | null;
}>(
`
SELECT n.id,
n.title,
n.description,
n.updated_at,
COUNT(DISTINCT e.id) AS edge_count
FROM nodes n
LEFT JOIN edges e
ON (e.from_node_id = n.id OR e.to_node_id = n.id)
GROUP BY n.id
ORDER BY edge_count DESC, n.updated_at DESC, n.id ASC
LIMIT ?
`,
[limit]
).rows;
const rows = db
.query<{
id: number;
title: string | null;
description: string | null;
updated_at: string;
edge_count: number | null;
}>(
`
SELECT n.id,
n.title,
n.description,
n.updated_at,
COUNT(DISTINCT e.id) AS edge_count
FROM nodes n
LEFT JOIN edges e
ON (e.from_node_id = n.id OR e.to_node_id = n.id)
GROUP BY n.id
ORDER BY edge_count DESC, n.updated_at DESC, n.id ASC
LIMIT ?
`,
[limit]
)
.rows;
return rows.map((row) => ({
id: row.id,
@@ -150,7 +151,7 @@ export function buildContextsBlock(limit = 12): string | null {
const lines: string[] = [
'User Contexts',
'Use contexts as the primary scope layer. Dimensions are secondary metadata and filters.',
'Contexts are optional soft hints. Use them when they are explicit and useful, but rely primarily on title, description, source, edges, and recency.',
'',
];
@@ -172,7 +173,7 @@ export function buildContextAnchorsBlock(limit = 12): string | null {
const lines: string[] = [
'Context Anchors',
'Each context anchor is the highest-edge node in that context. Use it as the starting graph waypoint for that scope.',
'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.',
'',
];
@@ -195,7 +196,7 @@ export function buildHubNodesBlock(limit = 5): string | null {
const lines: string[] = [
'Global Hub Diagnostics',
'These are secondary graph diagnostics only. Do not use them as the primary scope layer when contexts are available.',
'These are secondary graph diagnostics only. Do not treat them as the primary grounding mechanism.',
'',
];
@@ -210,24 +211,9 @@ export function buildHubNodesBlock(limit = 5): string | null {
}
export function getAutoContextSummaries(limit = 5): AutoContextSummary[] {
const settings = getAutoContextSettings();
if (!settings.autoContextEnabled) {
return [];
}
return getHubNodes(limit);
}
export function buildAutoContextBlock(limit = 12): string | null {
const settings = getAutoContextSettings();
if (!settings.autoContextEnabled) {
return null;
}
const sections = [
buildContextsBlock(limit),
buildContextAnchorsBlock(limit),
buildHubNodesBlock(5),
].filter(Boolean);
return sections.length > 0 ? sections.join('\n\n') : null;
export function buildAutoContextBlock(limit = 5): string | null {
return buildContextsBlock(limit);
}
-168
View File
@@ -1,168 +0,0 @@
import { getSQLiteClient } from '@/services/database/sqlite-client';
type ContextCandidate = {
id: number;
name: string;
description: string | null;
count: number;
anchor_title: string | null;
anchor_description: string | null;
};
type InferContextInput = {
title?: string | null;
description?: string | null;
source?: string | null;
dimensions?: string[] | null;
metadata?: unknown;
};
const STOP_WORDS = new Set([
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'has', 'i',
'in', 'is', 'it', 'its', 'of', 'on', 'or', 'that', 'the', 'their', 'this',
'to', 'was', 'with', 'you', 'your'
]);
function normalizeText(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9\s]+/g, ' ').replace(/\s+/g, ' ').trim();
}
function tokenize(value: string | null | undefined): string[] {
if (!value) return [];
return normalizeText(value)
.split(' ')
.map((token) => token.trim())
.filter((token) => token.length >= 2 && !STOP_WORDS.has(token));
}
function uniqueTokens(values: Array<string | null | undefined>): string[] {
return Array.from(new Set(values.flatMap((value) => tokenize(value))));
}
function safeStringify(value: unknown): string {
try {
return JSON.stringify(value ?? {});
} catch {
return '';
}
}
function fetchContextCandidates(): ContextCandidate[] {
const sqlite = getSQLiteClient();
return sqlite.query<ContextCandidate>(`
WITH context_counts AS (
SELECT c.id, c.name, c.description, 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.title AS anchor_title,
n.description AS anchor_description,
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.count,
ra.anchor_title,
ra.anchor_description
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
`).rows.map((row) => ({
id: Number(row.id),
name: row.name,
description: row.description ?? null,
count: Number(row.count ?? 0),
anchor_title: row.anchor_title ?? null,
anchor_description: row.anchor_description ?? null,
}));
}
function scoreContextCandidate(candidate: ContextCandidate, input: InferContextInput): number {
const titleText = normalizeText(input.title || '');
const descriptionText = normalizeText(input.description || '');
const sourceText = normalizeText((input.source || '').slice(0, 4000));
const metadataText = normalizeText(safeStringify(input.metadata));
const dimensionTokens = uniqueTokens(input.dimensions ?? []);
const contextName = normalizeText(candidate.name);
const contextNameTokens = tokenize(candidate.name);
const contextDescriptorTokens = uniqueTokens([
candidate.description,
candidate.anchor_title,
candidate.anchor_description,
]);
let score = 0;
if (contextName && (titleText.includes(contextName) || descriptionText.includes(contextName))) {
score += 80;
}
if (contextName && sourceText.includes(contextName)) {
score += 40;
}
for (const token of contextNameTokens) {
if (dimensionTokens.includes(token)) score += 30;
if (titleText.includes(token)) score += 16;
if (descriptionText.includes(token)) score += 12;
if (sourceText.includes(token)) score += 6;
if (metadataText.includes(token)) score += 4;
}
for (const token of contextDescriptorTokens) {
if (dimensionTokens.includes(token)) score += 8;
if (titleText.includes(token)) score += 4;
if (descriptionText.includes(token)) score += 3;
if (sourceText.includes(token)) score += 2;
}
return score;
}
export async function inferBestContextIdForNode(input: InferContextInput): Promise<number | null> {
const contexts = fetchContextCandidates();
if (contexts.length === 0) {
return null;
}
const ranked = contexts
.map((context) => ({
context,
score: scoreContextCandidate(context, input),
}))
.sort((a, b) =>
b.score - a.score ||
(b.context.count - a.context.count) ||
a.context.id - b.context.id
);
const best = ranked[0];
if (!best) {
return null;
}
if (best.score > 0) {
return best.context.id;
}
const research = contexts.find((context) => context.name.trim().toLowerCase() === 'research');
if (research) {
return research.id;
}
return ranked[0].context.id;
}
+3 -1
View File
@@ -177,7 +177,9 @@ export class ContextService {
}
async resolveContextId(input: { context_id?: number | null; context_name?: string | null }): Promise<number | null | undefined> {
const hasContextId = Object.prototype.hasOwnProperty.call(input, 'context_id');
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) {
+13 -6
View File
@@ -1,5 +1,5 @@
import { openai as openaiProvider } from '@ai-sdk/openai';
import { generateText } from 'ai';
import { openai as openaiProvider } from '@ai-sdk/openai';
import { hasValidOpenAiKey } from '../storage/apiKeys';
import type { CanonicalNodeMetadata } from '@/types/database';
@@ -16,11 +16,12 @@ export interface DescriptionInput {
pages?: number;
text_length?: number;
};
dimensions?: string[];
}
export { hasValidOpenAiKey } from '../storage/apiKeys';
/**
* Generate a context-rich description for a knowledge node.
* The result must cover what the artifact is, why it is in the graph, and workflow status.
*/
export async function generateDescription(input: DescriptionInput): Promise<string> {
if (!hasValidOpenAiKey()) {
console.log(`[DescriptionService] No valid OpenAI key, using fallback for: "${input.title}"`);
@@ -46,10 +47,13 @@ export async function generateDescription(input: DescriptionInput): Promise<stri
return description;
} catch (error) {
console.error('[DescriptionService] Error generating description:', error);
// Fallback: just use the title — more useful than a vague template
return `${input.title}. Added via Quick Add with no further context yet, so the reason it belongs in the graph is not fully inferred. It has not been reviewed yet.`.slice(0, 500);
}
}
export { hasValidOpenAiKey } from '../storage/apiKeys';
function buildDescriptionPrompt(input: DescriptionInput): string {
const sourceMetadata = input.metadata?.source_metadata as Record<string, unknown> | undefined;
const sourceType = typeof input.metadata?.type === 'string'
@@ -60,6 +64,8 @@ function buildDescriptionPrompt(input: DescriptionInput): string {
const normalizedSource = sourceType.toLowerCase();
const url = typeof input.link === 'string' ? input.link.trim() : '';
// Best-effort creator hint from structured metadata (when available),
// but never assume a particular extraction source (YouTube vs paper vs website vs note).
const creatorHint =
(typeof sourceMetadata?.author === 'string' ? sourceMetadata.author.trim() : '') ||
(typeof sourceMetadata?.channel_name === 'string' ? sourceMetadata.channel_name.trim() : '') ||
@@ -67,6 +73,7 @@ function buildDescriptionPrompt(input: DescriptionInput): string {
(typeof input.metadata?.channel_name === 'string' ? input.metadata.channel_name.trim() : '') ||
'';
// Best-effort publisher / container hint (less ideal than a true author, but better than nothing).
const publisherHint =
(typeof sourceMetadata?.site_name === 'string' ? sourceMetadata.site_name.trim() : '') ||
(typeof input.metadata?.site_name === 'string' ? input.metadata.site_name.trim() : '') ||
@@ -90,7 +97,6 @@ function buildDescriptionPrompt(input: DescriptionInput): string {
const lines: string[] = [`Title: ${input.title}`];
if (input.link) lines.push(`URL: ${input.link}`);
if (input.dimensions?.length) lines.push(`Dimensions: ${input.dimensions.join(', ')}`);
if (sourceMetadata?.channel_name || input.metadata?.channel_name) lines.push(`Channel: ${sourceMetadata?.channel_name || input.metadata?.channel_name}`);
if (sourceMetadata?.author || input.metadata?.author) lines.push(`Author: ${sourceMetadata?.author || input.metadata?.author}`);
if (sourceMetadata?.site_name || input.metadata?.site_name) lines.push(`Site: ${sourceMetadata?.site_name || input.metadata?.site_name}`);
@@ -116,7 +122,7 @@ RULES:
1) Name the format only if the context clearly supports it: "Podcast episode where…", "Blog post arguing…", "Personal note capturing…", "Research paper showing…", "Resume/CV for…", "Document likely containing…", "Idea that…"
2) Name people by role — channel/host is the creator, title figures are guests/subjects. Use the Creator hint if available.
3) State the actual claim, finding, or insight from the content — not a vague summary of the topic.
4) If the reason it belongs in the graph cannot be inferred from title, source excerpt, URL, metadata, or dimensions, say that naturally rather than inventing context.
4) If the reason it belongs in the graph cannot be inferred from title, source excerpt, URL, or metadata, say that naturally rather than inventing context.
5) If workflow status is unknown, say that naturally, for example by noting it has not been reviewed yet.
6) Do NOT use labels or headings like "WHAT:", "WHY:", or "STATUS:".
7) ABSOLUTELY FORBIDDEN — these words and phrases will be rejected: "discusses", "explores", "examines", "talks about", "is about", "delves into", "emphasizing the need for", "insightful for understanding", "relevant to", "important for", "useful for understanding". State things directly instead.
@@ -144,6 +150,7 @@ function sanitizeDescription(rawText: string, input: DescriptionInput): string {
return `${input.title}. Added via Quick Add with no further context yet, so the reason it belongs in the graph is not fully inferred. It has not been reviewed yet.`.slice(0, 500);
}
// Guard against weak generic openings from model drift.
const noGenericPrefix = singleLine.replace(
/^(your note|this note)\s*[—:-]\s*/i,
'Personal note capturing '
-189
View File
@@ -1,189 +0,0 @@
import { getSQLiteClient } from './sqlite-client';
export interface Dimension {
name: string;
description: string | null;
is_priority: boolean;
updated_at: string;
}
export interface LockedDimension {
name: string;
description: string | null;
count: number;
}
export class DimensionService {
/**
* Legacy compatibility shim. Dimensions are now flat, so there is no locked subset.
*/
static async getLockedDimensions(): Promise<LockedDimension[]> {
return [];
}
/**
* Automatic special-dimension assignment has been removed. Callers must provide dimensions explicitly.
*/
static async assignDimensions(nodeData: {
title: string;
content?: string;
link?: string;
description?: string;
}): Promise<{ locked: string[]; keywords: string[] }> {
console.log(`[DimensionAssignment] Skipped for "${nodeData.title}" — flat dimensions require explicit assignment.`);
return { locked: [], keywords: [] };
}
/**
* Legacy method for backwards compatibility
* @deprecated Use assignDimensions() instead
*/
static async assignLockedDimensions(nodeData: {
title: string;
content?: string;
link?: string;
}): Promise<string[]> {
const result = await this.assignDimensions(nodeData);
return result.locked;
}
/**
* Update dimension description
*/
static async updateDimensionDescription(name: string, description: string): Promise<void> {
const sqlite = getSQLiteClient();
sqlite.query(`
INSERT INTO dimensions(name, description, is_priority, updated_at)
VALUES (?, ?, 0, CURRENT_TIMESTAMP)
ON CONFLICT(name) DO UPDATE SET
description = ?,
updated_at = CURRENT_TIMESTAMP
`, [name, description, description]);
}
/**
* Get dimension by name with description
*/
static async getDimensionByName(name: string): Promise<Dimension | null> {
const sqlite = getSQLiteClient();
const result = sqlite.query(`
SELECT name, description, is_priority, updated_at
FROM dimensions
WHERE name = ?
`, [name]);
if (result.rows.length === 0) {
return null;
}
const row = result.rows[0] as any;
return {
name: row.name,
description: row.description,
is_priority: Boolean(row.is_priority),
updated_at: row.updated_at
};
}
/**
* Legacy no-op prompt builder retained only for backward compatibility.
*/
private static buildAssignmentPrompt(
nodeData: { title: string; notes?: string; link?: string; description?: string },
lockedDimensions: LockedDimension[]
): string {
// Use description as primary context, content as fallback
let nodeContextSection: string;
if (nodeData.description) {
const notesPreview = nodeData.notes?.slice(0, 500) || '';
nodeContextSection = `DESCRIPTION: ${nodeData.description}
NOTES PREVIEW: ${notesPreview}${nodeData.notes && nodeData.notes.length > 500 ? '...' : ''}`;
} else {
const notesPreview = nodeData.notes?.slice(0, 2000) || '';
nodeContextSection = `NOTES: ${notesPreview}${nodeData.notes && nodeData.notes.length > 2000 ? '...' : ''}`;
}
// Include ALL locked dimensions, using fallback text for those without descriptions
const dimensionsList = lockedDimensions
.map(d => {
const description = d.description && d.description.trim().length > 0
? d.description
: '(none - infer from name)';
return `DIMENSION: "${d.name}"\nDESCRIPTION: ${description}`;
})
.join('\n---\n');
return `Dimensions are now flat categories with no locked subset.
=== NODE TO CATEGORIZE ===
Title: ${nodeData.title}
${nodeContextSection}
URL: ${nodeData.link || 'none'}
=== LOCKED DIMENSIONS ===
CRITICAL: Read each dimension's DESCRIPTION carefully.
The description defines what belongs in that dimension.
Only assign if the content CLEARLY matches the description.
If unsure, skip it — better to miss than assign incorrectly.
AVAILABLE DIMENSIONS:
${dimensionsList}
=== RESPONSE FORMAT ===
LOCKED:
[dimension names from the list above, one per line, or "none"]`;
}
/**
* Legacy no-op parser retained only for backward compatibility.
*/
private static parseAssignmentResponse(
response: string,
availableDimensions: LockedDimension[]
): { locked: string[]; keywords: string[] } {
const lockedDimensions: string[] = [];
// Extract LOCKED section
const lockedMatch = response.match(/LOCKED:\s*([\s\S]*?)$/i);
if (lockedMatch) {
const lockedLines = lockedMatch[1].trim().split('\n');
for (const line of lockedLines) {
const dimensionName = line.trim().toLowerCase();
if (dimensionName === 'none' || dimensionName === '') {
continue;
}
// Find matching dimension (case-insensitive)
const matchedDimension = availableDimensions.find(
d => d.name.toLowerCase() === dimensionName
);
if (matchedDimension && !lockedDimensions.includes(matchedDimension.name)) {
lockedDimensions.push(matchedDimension.name);
}
}
}
return { locked: lockedDimensions, keywords: [] };
}
/**
* Create or get a keyword dimension (unlocked)
*/
static async ensureKeywordDimension(keyword: string): Promise<void> {
const sqlite = getSQLiteClient();
// INSERT OR IGNORE - if dimension exists, do nothing
sqlite.query(`
INSERT OR IGNORE INTO dimensions(name, description, is_priority, updated_at)
VALUES (?, ?, 0, CURRENT_TIMESTAMP)
`, [keyword, null]);
}
}
export const dimensionService = new DimensionService();
@@ -1,29 +0,0 @@
import { getSQLiteClient } from './sqlite-client';
import { normalizeDimensionName } from './quality';
export function getUnknownDimensions(values: string[]): string[] {
if (values.length === 0) return [];
const sqlite = getSQLiteClient();
const placeholders = values.map(() => '?').join(', ');
const result = sqlite.query<{ name: string }>(
`SELECT name FROM dimensions WHERE name IN (${placeholders})`,
values
);
const existing = new Set(
result.rows
.map(row => (typeof row.name === 'string' ? normalizeDimensionName(row.name) : ''))
.filter(Boolean)
);
return values.filter(value => !existing.has(normalizeDimensionName(value)));
}
export function formatUnknownDimensionsError(values: string[]): string {
if (values.length === 1) {
return `Unknown dimension: "${values[0]}". Create it first or use an existing dimension.`;
}
return `Unknown dimensions: ${values.map(value => `"${value}"`).join(', ')}. Create them first or use existing dimensions.`;
}
+20 -41
View File
@@ -2,11 +2,11 @@ import { getSQLiteClient } from './sqlite-client';
import { Edge, EdgeContext, EdgeData, EdgeCreatedVia, NodeConnection, Node } from '@/types/database';
import { eventBroadcaster } from '../events';
import { nodeService } from './nodes';
import { getOpenAiKey } from '../storage/apiKeys';
import { generateText } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
import { validateEdgeExplanation } from './quality';
import { hasValidOpenAiKey } from '../storage/apiKeys';
const inferredEdgeContextSchema = z.object({
type: z.enum(['created_by', 'part_of', 'source_of', 'related_to']),
@@ -53,14 +53,6 @@ async function inferEdgeContext(params: {
return { type: 'related_to', confidence: 0.8, swap_direction: false };
}
// If no API key is configured, degrade gracefully.
// We still enforce explanation, but fall back to "related_to" classification.
const apiKey = getOpenAiKey();
if (!apiKey) {
return { type: 'related_to', confidence: 0.0, swap_direction: false };
}
const provider = createOpenAI({ apiKey });
const prompt = [
`Given two nodes and an explanation, determine the relationship type and direction.`,
``,
@@ -83,8 +75,12 @@ async function inferEdgeContext(params: {
].join('\n');
try {
if (!hasValidOpenAiKey()) {
return { type: 'related_to', confidence: 0.2, swap_direction: false };
}
const { text } = await generateText({
model: provider('gpt-4o-mini'),
model: openai('gpt-4o-mini'),
prompt,
temperature: 0.0,
maxOutputTokens: 120,
@@ -120,18 +116,6 @@ async function autoInferEdge(params: {
}): Promise<{ explanation: string; type: EdgeContext['type']; confidence: number; swap_direction: boolean }> {
const { fromNode, toNode } = params;
const apiKey = getOpenAiKey();
if (!apiKey) {
// Fallback without AI
return {
explanation: `Connection to ${toNode.title}; exact relationship uncertain.`,
type: 'related_to',
confidence: 0.0,
swap_direction: false,
};
}
const provider = createOpenAI({ apiKey });
const prompt = [
`Given two knowledge base nodes, determine how they are related.`,
``,
@@ -157,8 +141,17 @@ async function autoInferEdge(params: {
].join('\n');
try {
if (!hasValidOpenAiKey()) {
return {
explanation: `Connection to ${toNode.title}; exact relationship uncertain.`,
type: 'related_to',
confidence: 0.2,
swap_direction: false,
};
}
const { text } = await generateText({
model: provider('gpt-4o-mini'),
model: openai('gpt-4o-mini'),
prompt,
temperature: 0.0,
maxOutputTokens: 150,
@@ -477,15 +470,14 @@ export class EdgeService {
WHEN e.from_node_id = ? THEN n_to.title
ELSE n_from.title
END as connected_node_title,
CASE
WHEN e.from_node_id = ? THEN n_to.link
CASE WHEN e.from_node_id = ? THEN n_to.link
ELSE n_from.link
END as connected_node_link,
CASE
WHEN e.from_node_id = ? THEN n_to.source
ELSE n_from.source
END as connected_node_source,
CASE
CASE
WHEN e.from_node_id = ? THEN n_to.metadata
ELSE n_from.metadata
END as connected_node_metadata,
@@ -496,17 +488,7 @@ export class EdgeService {
CASE
WHEN e.from_node_id = ? THEN n_to.updated_at
ELSE n_from.updated_at
END as connected_node_updated_at,
CASE
WHEN e.from_node_id = ? THEN (
SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n_to.id
)
ELSE (
SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n_from.id
)
END as connected_node_dimensions_json
END as connected_node_updated_at
FROM edges e
LEFT JOIN nodes n_from ON e.from_node_id = n_from.id
LEFT JOIN nodes n_to ON e.to_node_id = n_to.id
@@ -521,7 +503,6 @@ export class EdgeService {
nodeId,
nodeId,
nodeId,
nodeId,
nodeId
]);
@@ -543,7 +524,6 @@ export class EdgeService {
id: row.connected_node_id,
title: row.connected_node_title,
link: row.connected_node_link,
dimensions: row.connected_node_dimensions,
embedding: undefined, // Not needed for display
source: row.connected_node_source,
metadata: row.connected_node_metadata,
@@ -587,7 +567,6 @@ export class EdgeService {
id: row.connected_node_id,
title: row.connected_node_title,
link: row.connected_node_link,
dimensions: JSON.parse(row.connected_node_dimensions_json || '[]'),
embedding: undefined, // Not needed for display
source: row.connected_node_source,
metadata: typeof row.connected_node_metadata === 'string' ? JSON.parse(row.connected_node_metadata) : row.connected_node_metadata,
+1 -3
View File
@@ -2,7 +2,6 @@
export { nodeService, NodeService } from './nodes';
export { chunkService, ChunkService } from './chunks';
export { edgeService, EdgeService } from './edges';
export { dimensionService, DimensionService } from './dimensionService';
export { contextService, ContextService } from './contextService';
// export { HelperService } from './helpers'; // Removed - migrated to JSON-based service
@@ -39,7 +38,6 @@ async function checkSQLiteDatabaseHealth(): Promise<{
const sqlite = getSQLiteClient();
const connected = await sqlite.testConnection();
const vectorCapability = sqlite.getVectorCapability();
if (!connected) {
return {
connected: false,
@@ -49,7 +47,7 @@ async function checkSQLiteDatabaseHealth(): Promise<{
};
}
const vectorExtension = vectorCapability.available;
const vectorExtension = await sqlite.checkVectorExtension();
// Check if main tables exist
const tables = await sqlite.checkTables();
+74 -152
View File
@@ -5,7 +5,9 @@ import { EmbeddingService } from '@/services/embeddings';
import { scoreNodeSearchMatch } from './searchRanking';
import { buildCanonicalNodeMetadata, mergeNodeMetadata } from '@/services/nodes/metadata';
type NodeRow = Node & { dimensions_json: string; context_json: string | null };
type NodeRow = Node & {
context_json: string | null;
};
type NodeSearchRow = NodeRow & { rank?: number; similarity?: number };
function sanitizeFtsQuery(input: string): string {
@@ -81,8 +83,15 @@ export class NodeService {
}
async countNodes(filters: NodeFilters = {}): Promise<number> {
const { dimensions, search, dimensionsMatch = 'any',
createdAfter, createdBefore, eventAfter, eventBefore, chunkStatus, contextId } = filters;
const {
search,
createdAfter,
createdBefore,
eventAfter,
eventBefore,
chunkStatus,
contextId,
} = filters;
if (search?.trim()) {
return this.countSearchNodesSQLite(filters);
@@ -93,24 +102,6 @@ export class NodeService {
let query = `SELECT COUNT(*) as total FROM nodes n WHERE 1=1`;
const params: any[] = [];
if (dimensions && dimensions.length > 0) {
if (dimensionsMatch === 'all' && dimensions.length > 1) {
query += ` AND (
SELECT COUNT(DISTINCT nd.dimension) FROM node_dimensions nd
WHERE nd.node_id = n.id
AND nd.dimension IN (${dimensions.map(() => '?').join(',')})
) = ?`;
params.push(...dimensions, dimensions.length);
} else {
query += ` AND EXISTS (
SELECT 1 FROM node_dimensions nd
WHERE nd.node_id = n.id
AND nd.dimension IN (${dimensions.map(() => '?').join(',')})
)`;
params.push(...dimensions);
}
}
if (search) {
query += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.source LIKE ? COLLATE NOCASE)`;
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
@@ -130,8 +121,18 @@ export class NodeService {
// PostgreSQL path removed in SQLite-only consolidation
private async getNodesSQLite(filters: NodeFilters = {}): Promise<Node[]> {
const { dimensions, search, limit = 100, offset = 0, sortBy, dimensionsMatch = 'any',
createdAfter, createdBefore, eventAfter, eventBefore, chunkStatus, contextId } = filters;
const {
search,
limit = 100,
offset = 0,
sortBy,
createdAfter,
createdBefore,
eventAfter,
eventBefore,
chunkStatus,
contextId,
} = filters;
if (search?.trim()) {
return this.searchNodesSQLite(filters);
@@ -139,13 +140,10 @@ export class NodeService {
const sqlite = getSQLiteClient();
// Use nodes_v view for array-like dimensions behavior (exclude embedding BLOB for performance)
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,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
CASE
WHEN c.id IS NULL THEN NULL
ELSE json_object('id', c.id, 'name', c.name, 'description', c.description, 'icon', c.icon)
@@ -157,27 +155,6 @@ export class NodeService {
`;
const params: any[] = [];
// Filter by dimensions (SQLite JOIN with node_dimensions)
if (dimensions && dimensions.length > 0) {
if (dimensionsMatch === 'all' && dimensions.length > 1) {
// AND logic: node must have ALL specified dimensions
query += ` AND (
SELECT COUNT(DISTINCT nd.dimension) FROM node_dimensions nd
WHERE nd.node_id = n.id
AND nd.dimension IN (${dimensions.map(() => '?').join(',')})
) = ?`;
params.push(...dimensions, dimensions.length);
} else {
// OR logic: node must have at least one of the specified dimensions
query += ` AND EXISTS (
SELECT 1 FROM node_dimensions nd
WHERE nd.node_id = n.id
AND nd.dimension IN (${dimensions.map(() => '?').join(',')})
)`;
params.push(...dimensions);
}
}
// Text search in title, description, and source (SQLite LIKE with COLLATE NOCASE)
if (search) {
query += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.source LIKE ? COLLATE NOCASE)`;
@@ -251,7 +228,7 @@ export class NodeService {
const result = sqlite.query<NodeRow>(query, params);
// Parse dimensions_json and metadata back for compatibility
// Parse metadata and normalize deprecated compatibility fields.
return result.rows.map(row => this.mapNodeRow(row));
}
@@ -267,8 +244,6 @@ 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,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
CASE
WHEN c.id IS NULL THEN NULL
ELSE json_object('id', c.id, 'name', c.name, 'description', c.description, 'icon', c.icon)
@@ -298,7 +273,6 @@ export class NodeService {
source,
link,
event_date,
dimensions = [],
chunk_status,
metadata = {},
context_id,
@@ -327,20 +301,10 @@ export class NodeService {
const id = Number(nodeResult.lastInsertRowid);
// Insert dimensions separately with INSERT OR IGNORE for safety
if (dimensions.length > 0) {
const stmt = sqlite.prepare(
"INSERT OR IGNORE INTO node_dimensions (node_id, dimension) VALUES (?, ?)"
);
for (const dimension of dimensions) {
stmt.run(id, dimension);
}
}
return id; // Returns number directly
});
// Get the created node with dimensions (outside transaction)
// Re-read the created node outside the transaction so callers get the canonical shape.
const createdNode = await this.getNodeByIdSQLite(nodeId);
if (!createdNode) {
throw new Error('Failed to create node');
@@ -363,7 +327,7 @@ export class NodeService {
// PostgreSQL path removed in SQLite-only consolidation
private async updateNodeSQLite(id: number, updates: Partial<Node>): Promise<Node> {
const { title, description, source, link, event_date, dimensions, metadata } = updates;
const { title, description, source, link, event_date, metadata } = updates;
const now = new Date().toISOString();
const sqlite = getSQLiteClient();
@@ -411,14 +375,6 @@ export class NodeService {
stmt.run(...params);
}
// Handle dimensions separately
if (Array.isArray(dimensions)) {
sqlite.prepare('DELETE FROM node_dimensions WHERE node_id = ?').run(id);
const dimStmt = sqlite.prepare('INSERT OR IGNORE INTO node_dimensions (node_id, dimension) VALUES (?, ?)');
for (const dim of dimensions) {
dimStmt.run(id, dim);
}
}
});
// Get updated node
@@ -458,28 +414,21 @@ export class NodeService {
});
}
// Dimension-based filtering methods
async getNodesByDimension(dimension: string): Promise<Node[]> {
return this.getNodes({ dimensions: [dimension] });
}
async searchNodes(searchTerm: string, limit = 50): Promise<Node[]> {
return this.getNodes({ search: searchTerm, limit });
}
private mapNodeRow(row: NodeRow): Node {
const { context_json, ...baseRow } = row;
return {
...row,
dimensions: JSON.parse(row.dimensions_json || '[]'),
metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null,
context: row.context_json ? JSON.parse(row.context_json) : null,
...baseRow,
metadata: baseRow.metadata ? (typeof baseRow.metadata === 'string' ? JSON.parse(baseRow.metadata) : baseRow.metadata) : null,
context: context_json ? JSON.parse(context_json) : null,
};
}
private buildNodeFilterClauses(filters: NodeFilters, alias = 'n'): { clauses: string[]; params: any[] } {
const {
dimensions,
dimensionsMatch = 'any',
createdAfter,
createdBefore,
eventAfter,
@@ -490,24 +439,6 @@ export class NodeService {
const clauses: string[] = [];
const params: any[] = [];
if (dimensions && dimensions.length > 0) {
if (dimensionsMatch === 'all' && dimensions.length > 1) {
clauses.push(`(
SELECT COUNT(DISTINCT nd.dimension) FROM node_dimensions nd
WHERE nd.node_id = ${alias}.id
AND nd.dimension IN (${dimensions.map(() => '?').join(',')})
) = ?`);
params.push(...dimensions, dimensions.length);
} else {
clauses.push(`EXISTS (
SELECT 1 FROM node_dimensions nd
WHERE nd.node_id = ${alias}.id
AND nd.dimension IN (${dimensions.map(() => '?').join(',')})
)`);
params.push(...dimensions);
}
}
if (createdAfter) { clauses.push(`${alias}.created_at >= ?`); params.push(createdAfter); }
if (createdBefore) { clauses.push(`${alias}.created_at < ?`); params.push(createdBefore); }
if (eventAfter) { clauses.push(`${alias}.event_date >= ?`); params.push(eventAfter); }
@@ -567,26 +498,30 @@ export class NodeService {
if (!search) return 0;
const ftsQuery = sanitizeFtsQuery(search);
const ftsExists = sqlite.prepare(
const ftsExists = sqlite.isNodesFtsUsable() && sqlite.prepare(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='nodes_fts'"
).get();
const { clauses, params } = this.buildNodeFilterClauses(filters);
if (ftsExists && ftsQuery) {
const whereClauses = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
const result = sqlite.query<{ total: number }>(`
WITH matched_nodes AS (
SELECT rowid
FROM nodes_fts
WHERE nodes_fts MATCH ?
)
SELECT COUNT(*) as total
FROM matched_nodes mn
JOIN nodes n ON n.id = mn.rowid
${whereClauses}
`, [ftsQuery, ...params]);
try {
const result = sqlite.query<{ total: number }>(`
WITH matched_nodes AS (
SELECT rowid
FROM nodes_fts
WHERE nodes_fts MATCH ?
)
SELECT COUNT(*) as total
FROM matched_nodes mn
JOIN nodes n ON n.id = mn.rowid
${whereClauses}
`, [ftsQuery, ...params]);
return Number(result.rows[0]?.total ?? 0);
return Number(result.rows[0]?.total ?? 0);
} catch (error) {
sqlite.disableNodesFts('nodes_fts query failed during count search', error);
}
}
const words = search.split(/\s+/).filter(Boolean);
@@ -615,7 +550,7 @@ export class NodeService {
const ftsQuery = sanitizeFtsQuery(search);
if (!ftsQuery) return [];
const ftsExists = sqlite.prepare(
const ftsExists = sqlite.isNodesFtsUsable() && sqlite.prepare(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='nodes_fts'"
).get();
if (!ftsExists) return [];
@@ -633,12 +568,15 @@ 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,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
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,
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 ?
@@ -646,7 +584,7 @@ export class NodeService {
return result.rows;
} catch (error) {
console.warn('[NodeSearch] FTS search failed, falling back to LIKE:', error);
sqlite.disableNodesFts('nodes_fts query failed during node search', error);
return [];
}
}
@@ -662,10 +600,13 @@ 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,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
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
FROM nodes n
LEFT JOIN contexts c ON c.id = n.context_id
WHERE 1=1
`;
const queryParams = [...params];
@@ -707,10 +648,13 @@ 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,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
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
FROM nodes n
LEFT JOIN contexts c ON c.id = n.context_id
WHERE 1=1
`;
const queryParams = [...params];
@@ -781,12 +725,15 @@ 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,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
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,
(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 ?
@@ -829,31 +776,6 @@ export class NodeService {
return updatedNodes;
}
// Get all unique dimensions for UI filtering
async getAllDimensions(): Promise<string[]> {
const sqlite = getSQLiteClient();
const query = `
SELECT DISTINCT dimension
FROM node_dimensions
ORDER BY dimension
`;
const result = sqlite.query<{dimension: string}>(query);
return result.rows.map(row => row.dimension);
}
// Get dimension usage statistics
async getDimensionStats(): Promise<{dimension: string, count: number}[]> {
const sqlite = getSQLiteClient();
const query = `
SELECT dimension, COUNT(*) as count
FROM node_dimensions
GROUP BY dimension
ORDER BY count DESC
`;
const result = sqlite.query<{dimension: string, count: number}>(query);
return result.rows;
}
}
// Export singleton instance
+1 -36
View File
@@ -1,34 +1,10 @@
const WEAK_DESCRIPTION_PATTERNS = /\b(discusses|explores|examines|talks about|is about|delves into)\b/i;
const EXPLICIT_ENTITY_PATTERNS = /\b(article|artifact|book|brief|claim|company|concept|conversation|dataset|decision|dimension|document|episode|essay|event|guide|idea|insight|interview|lesson|link|node|note|paper|person|plan|placeholder|podcast|post|presentation|project|question|record|research|resource|skill|source|status|summary|talk|target|test node|thread|tool|transcript|tweet|update|video|website|workflow)\b/i;
const EXPLICIT_ENTITY_PATTERNS = /\b(article|artifact|book|brief|claim|company|concept|conversation|dataset|decision|document|episode|essay|event|guide|idea|insight|interview|lesson|link|node|note|paper|person|plan|placeholder|podcast|post|presentation|project|question|record|research|resource|skill|source|status|summary|talk|target|test node|thread|tool|transcript|tweet|update|video|website|workflow)\b/i;
const UNCERTAINTY_PATTERNS = /\b(likely|probably|possibly|appears to be|seems to be|unclear|uncertain)\b/i;
const WHY_PATTERNS = /(why added:|added (?:after|as|for|because|to)|follow-?on|queued for|saved for|relevant because|connected to|not inferred|belongs in the graph because|belongs here because|captures .* idea|ties directly into|ties into)/i;
const STATUS_PATTERNS = /(status:|queued|not yet reviewed|in progress|processed|reviewed|saved for later|to review|to read|to watch|to listen|draft|not yet published|unpublished)/i;
const GENERIC_EDGE_PATTERNS = /^(related|related to|connected|connected to|association|associated with)$/i;
export function normalizeDimensionName(value: string): string {
return value.trim().replace(/\s+/g, ' ');
}
export function normalizeDimensions(values: unknown, max = 5): string[] {
if (!Array.isArray(values)) return [];
const seen = new Set<string>();
const normalized: string[] = [];
for (const value of values) {
if (typeof value !== 'string') continue;
const trimmed = normalizeDimensionName(value);
if (!trimmed) continue;
const key = trimmed.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
normalized.push(trimmed);
if (normalized.length >= max) break;
}
return normalized;
}
export function validateExplicitDescription(description: string): string | null {
const text = description.trim();
if (text.length > 500) {
@@ -84,14 +60,3 @@ export function validateEdgeExplanation(explanation: string): string | null {
}
return null;
}
export function validateDimensionDescription(description: string): string | null {
const text = description.trim();
if (!text) {
return 'Dimension description is required.';
}
if (text.length > 500) {
return 'Description must be 500 characters or less.';
}
return null;
}
+459 -105
View File
@@ -25,6 +25,8 @@ class SQLiteClient {
private config: SQLiteConfig;
private readonly readOnly: boolean;
private readonly vectorCapability: VectorCapability;
private nodesFtsUsable = true;
private nodesFtsDisabledReason: string | null = null;
private constructor() {
this.config = this.getSQLiteConfig();
@@ -61,6 +63,7 @@ class SQLiteClient {
this.db.pragma('temp_store = memory');
this.db.pragma('busy_timeout = 5000');
this.ensureCoreSchema();
// Ensure vector virtual tables are present and healthy
if (this.vectorCapability.available) {
this.ensureVectorTables();
@@ -162,6 +165,25 @@ class SQLiteClient {
return this.vectorCapability;
}
public isNodesFtsUsable(): boolean {
return this.nodesFtsUsable;
}
public disableNodesFts(reason: string, error?: unknown): void {
this.nodesFtsUsable = false;
if (this.nodesFtsDisabledReason === reason) {
return;
}
this.nodesFtsDisabledReason = reason;
if (error && !this.isSqliteCorruptError(error)) {
console.warn(`[SQLite] nodes_fts disabled: ${reason}`, error);
return;
}
console.warn(`[SQLite] nodes_fts disabled: ${reason}. Falling back to LIKE search for this database session.`);
}
public async checkTables(): Promise<string[]> {
try {
const result = this.query(
@@ -215,17 +237,283 @@ class SQLiteClient {
this.ensureVectorExtensions();
}
private ensureCoreSchema(): void {
if (this.readOnly) {
return;
}
this.db.exec(`
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY,
title TEXT,
description TEXT,
source TEXT,
link TEXT,
event_date TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
metadata TEXT,
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
);
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 UNIQUE INDEX IF NOT EXISTS idx_contexts_name_normalized
ON contexts(LOWER(TRIM(name)));
CREATE TABLE IF NOT EXISTS chunks (
id INTEGER PRIMARY KEY,
node_id INTEGER NOT NULL,
chunk_idx INTEGER,
text TEXT NOT NULL,
embedding BLOB,
embedding_type TEXT DEFAULT 'openai',
metadata TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS chats (
id INTEGER PRIMARY KEY,
chat_type TEXT,
helper_name TEXT,
agent_type TEXT DEFAULT 'orchestrator',
delegation_id INTEGER,
user_message TEXT,
assistant_message TEXT,
thread_id TEXT,
focused_node_id INTEGER,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
metadata TEXT,
FOREIGN KEY (focused_node_id) REFERENCES nodes(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_nodes_updated_at ON nodes(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_chunks_node_id ON chunks(node_id);
`);
this.ensureEdgesTableSchema();
}
private ensureEdgesTableSchema(): void {
const hasEdgesTable = this.db
.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='edges'")
.get();
if (!hasEdgesTable) {
this.db.exec(`
CREATE TABLE edges (
id INTEGER PRIMARY KEY,
from_node_id INTEGER NOT NULL,
to_node_id INTEGER NOT NULL,
source TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
context TEXT,
explanation TEXT,
FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE,
FOREIGN KEY (to_node_id) REFERENCES nodes(id) ON DELETE CASCADE
);
`);
} else {
const edgeCols = this.db.prepare('PRAGMA table_info(edges)').all() as Array<{ name: string }>;
const edgeColNames = new Set(edgeCols.map((col) => col.name));
const needsLegacyRewrite =
!edgeColNames.has('from_node_id') ||
!edgeColNames.has('to_node_id') ||
!edgeColNames.has('source') ||
!edgeColNames.has('created_at') ||
!edgeColNames.has('context') ||
edgeColNames.has('from_id') ||
edgeColNames.has('to_id') ||
edgeColNames.has('description') ||
edgeColNames.has('updated_at');
if (needsLegacyRewrite) {
this.rebuildLegacyEdgesTable(edgeColNames);
}
}
this.db.exec(`
CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_node_id);
CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_node_id);
`);
}
private rebuildLegacyEdgesTable(edgeColNames: Set<string>): void {
const fromExpr = edgeColNames.has('from_node_id')
? 'from_node_id'
: edgeColNames.has('from_id')
? 'from_id'
: 'NULL';
const toExpr = edgeColNames.has('to_node_id')
? 'to_node_id'
: edgeColNames.has('to_id')
? 'to_id'
: 'NULL';
const sourceExpr = edgeColNames.has('source') ? 'source' : "'legacy'";
const createdAtExpr = edgeColNames.has('created_at') ? 'created_at' : 'CURRENT_TIMESTAMP';
const contextExpr = edgeColNames.has('context') ? 'context' : 'NULL';
const explanationExpr = edgeColNames.has('explanation')
? 'explanation'
: edgeColNames.has('description')
? 'description'
: edgeColNames.has('context')
? "CASE WHEN json_valid(context) THEN json_extract(context, '$.explanation') ELSE NULL END"
: 'NULL';
console.log('Migrating legacy edges table to canonical schema');
let flippedForeignKeys = false;
try {
this.db.exec('PRAGMA foreign_keys=OFF;');
flippedForeignKeys = true;
} catch {}
try {
this.db.exec('BEGIN TRANSACTION;');
this.db.exec(`
DROP INDEX IF EXISTS idx_edges_from;
DROP INDEX IF EXISTS idx_edges_to;
ALTER TABLE edges RENAME TO edges_legacy_migration;
CREATE TABLE edges (
id INTEGER PRIMARY KEY,
from_node_id INTEGER NOT NULL,
to_node_id INTEGER NOT NULL,
source TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
context TEXT,
explanation TEXT,
FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE,
FOREIGN KEY (to_node_id) REFERENCES nodes(id) ON DELETE CASCADE
);
INSERT INTO edges (id, from_node_id, to_node_id, source, created_at, context, explanation)
SELECT
id,
${fromExpr},
${toExpr},
${sourceExpr},
COALESCE(${createdAtExpr}, CURRENT_TIMESTAMP),
${contextExpr},
${explanationExpr}
FROM edges_legacy_migration
WHERE ${fromExpr} IS NOT NULL
AND ${toExpr} IS NOT NULL;
DROP TABLE edges_legacy_migration;
COMMIT;
`);
} catch (error) {
try {
this.db.exec('ROLLBACK;');
} catch {}
throw error;
} finally {
if (flippedForeignKeys) {
try {
this.db.exec('PRAGMA foreign_keys=ON;');
} catch {}
}
}
}
private rebuildLegacyChatsTable(chatColNames: Set<string>): void {
const chatTypeExpr = chatColNames.has('chat_type') ? 'chat_type' : 'NULL';
const helperNameExpr = chatColNames.has('helper_name')
? 'helper_name'
: chatColNames.has('title')
? 'title'
: 'NULL';
const agentTypeExpr = chatColNames.has('agent_type')
? "COALESCE(agent_type, 'orchestrator')"
: "'orchestrator'";
const delegationIdExpr = chatColNames.has('delegation_id') ? 'delegation_id' : 'NULL';
const userMessageExpr = chatColNames.has('user_message') ? 'user_message' : 'NULL';
const assistantMessageExpr = chatColNames.has('assistant_message') ? 'assistant_message' : 'NULL';
const threadIdExpr = chatColNames.has('thread_id') ? 'thread_id' : 'NULL';
const focusedNodeIdExpr = chatColNames.has('focused_node_id') ? 'focused_node_id' : 'NULL';
const createdAtExpr = chatColNames.has('created_at') ? 'created_at' : 'CURRENT_TIMESTAMP';
const metadataExpr = chatColNames.has('metadata') ? 'metadata' : 'NULL';
console.log('Migrating legacy chats table to canonical schema');
let flippedForeignKeys = false;
try {
this.db.exec('PRAGMA foreign_keys=OFF;');
flippedForeignKeys = true;
} catch {}
try {
this.db.exec('BEGIN TRANSACTION;');
this.db.exec(`
DROP INDEX IF EXISTS idx_chats_thread;
ALTER TABLE chats RENAME TO chats_legacy_cleanup;
CREATE TABLE chats (
id INTEGER PRIMARY KEY,
chat_type TEXT,
helper_name TEXT,
agent_type TEXT DEFAULT 'orchestrator',
delegation_id INTEGER,
user_message TEXT,
assistant_message TEXT,
thread_id TEXT,
focused_node_id INTEGER,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
metadata TEXT,
FOREIGN KEY (focused_node_id) REFERENCES nodes(id) ON DELETE SET NULL
);
INSERT INTO chats (
id, chat_type, helper_name, agent_type, delegation_id,
user_message, assistant_message, thread_id, focused_node_id,
created_at, metadata
)
SELECT
id,
${chatTypeExpr},
${helperNameExpr},
${agentTypeExpr},
${delegationIdExpr},
${userMessageExpr},
${assistantMessageExpr},
${threadIdExpr},
${focusedNodeIdExpr},
COALESCE(${createdAtExpr}, CURRENT_TIMESTAMP),
${metadataExpr}
FROM chats_legacy_cleanup;
DROP TABLE chats_legacy_cleanup;
COMMIT;
`);
} catch (error) {
try {
this.db.exec('ROLLBACK;');
} catch {}
throw error;
} finally {
if (flippedForeignKeys) {
try {
this.db.exec('PRAGMA foreign_keys=ON;');
} catch {}
}
}
}
private ensureLoggingAndMemorySchema(): void {
if (this.readOnly) {
return;
}
try {
const hasReadyLogs = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='logs'").get();
if (hasReadyLogs) {
return;
}
// 1) If logs table missing but legacy memory table exists, migrate
// Existing installs may already have logs but still need the idempotent schema pass below.
// Only skip the legacy memory rename step when logs already exists.
const hasLogs = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='logs'").get();
const hasLegacyMemory = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='memory'").get();
if (!hasLogs && hasLegacyMemory) {
@@ -263,6 +551,11 @@ class SQLiteClient {
}
};
ensureNodeCol('description', "ALTER TABLE nodes ADD COLUMN description TEXT;");
ensureNodeCol('link', 'ALTER TABLE nodes ADD COLUMN link TEXT;');
ensureNodeCol('source', 'ALTER TABLE nodes ADD COLUMN source TEXT;');
ensureNodeCol('metadata', 'ALTER TABLE nodes ADD COLUMN metadata TEXT;');
ensureNodeCol('created_at', "ALTER TABLE nodes ADD COLUMN created_at TEXT DEFAULT CURRENT_TIMESTAMP;");
ensureNodeCol('updated_at', "ALTER TABLE nodes ADD COLUMN updated_at TEXT DEFAULT CURRENT_TIMESTAMP;");
} catch (nodeErr) {
console.warn('Failed to ensure nodes columns:', nodeErr);
}
@@ -279,6 +572,25 @@ class SQLiteClient {
console.warn('Failed to ensure chats.created_at column:', chatErr);
}
// Normalize legacy chats table before creating chat triggers or views that reference modern columns.
try {
const hasChatsTable = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chats'").get();
if (hasChatsTable) {
const chatCols = this.db.prepare('PRAGMA table_info(chats)').all() as Array<{ name: string }>;
const chatColNames = new Set(chatCols.map((col) => col.name));
const needsChatRewrite =
chatColNames.has('focused_memory_id') ||
['chat_type', 'helper_name', 'agent_type', 'delegation_id', 'user_message', 'assistant_message', 'thread_id', 'focused_node_id', 'created_at', 'metadata']
.some((name) => !chatColNames.has(name));
if (needsChatRewrite) {
this.rebuildLegacyChatsTable(chatColNames);
}
}
} catch (chatSchemaErr) {
console.warn('Failed to normalize chats schema before log setup:', chatSchemaErr);
}
// 3) Helpful indexes on logs (clean up old names first)
this.db.exec(`
DROP INDEX IF EXISTS idx_memory_ts;
@@ -494,55 +806,20 @@ class SQLiteClient {
if (hasChats) {
try {
let chatCols = this.db.prepare('PRAGMA table_info(chats)').all() as any[];
const hasFocusedMemoryId = chatCols.some((c: any) => c.name === 'focused_memory_id');
if (hasFocusedMemoryId) {
console.log('Removing legacy chats.focused_memory_id column');
let flippedForeignKeys = false;
try {
this.db.exec('PRAGMA foreign_keys=OFF;');
flippedForeignKeys = true;
this.db.exec(`
BEGIN TRANSACTION;
ALTER TABLE chats RENAME TO chats_legacy_cleanup;
CREATE TABLE chats (
id INTEGER PRIMARY KEY,
chat_type TEXT,
helper_name TEXT,
agent_type TEXT DEFAULT 'orchestrator',
delegation_id INTEGER,
user_message TEXT,
assistant_message TEXT,
thread_id TEXT,
focused_node_id INTEGER,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP),
metadata TEXT,
FOREIGN KEY (focused_node_id) REFERENCES nodes(id) ON DELETE SET NULL
);
INSERT INTO chats (
id, chat_type, helper_name, agent_type, delegation_id,
user_message, assistant_message, thread_id, focused_node_id,
created_at, metadata
)
SELECT id, chat_type, helper_name, agent_type, delegation_id,
user_message, assistant_message, thread_id, focused_node_id,
created_at, metadata
FROM chats_legacy_cleanup;
DROP TABLE chats_legacy_cleanup;
CREATE INDEX IF NOT EXISTS idx_chats_thread ON chats(thread_id);
COMMIT;
`);
} catch (migrationErr) {
console.warn('Failed to migrate chats table (focused_memory_id removal):', migrationErr);
try { this.db.exec('ROLLBACK;'); } catch {}
} finally {
if (flippedForeignKeys) {
try { this.db.exec('PRAGMA foreign_keys=ON;'); } catch {}
}
}
const chatColNames = new Set(chatCols.map((c: any) => c.name));
const needsChatRewrite =
chatColNames.has('focused_memory_id') ||
['chat_type', 'helper_name', 'agent_type', 'delegation_id', 'user_message', 'assistant_message', 'thread_id', 'focused_node_id', 'created_at', 'metadata']
.some((name) => !chatColNames.has(name));
if (needsChatRewrite) {
this.rebuildLegacyChatsTable(chatColNames);
chatCols = this.db.prepare('PRAGMA table_info(chats)').all() as any[];
}
this.db.exec("CREATE INDEX IF NOT EXISTS idx_chats_thread ON chats(thread_id);");
if (chatCols.some((c: any) => c.name === 'thread_id')) {
this.db.exec("CREATE INDEX IF NOT EXISTS idx_chats_thread ON chats(thread_id);");
}
const ensureCol = (name: string, ddl: string) => {
if (!chatCols.some((c: any) => c.name === name)) {
@@ -578,51 +855,7 @@ class SQLiteClient {
console.warn('Failed to drop legacy memory pipeline tables:', dropLegacyErr);
}
// 9) Ensure dimensions table exists (v0.1.16+ schema migration)
const hasDimensions = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='dimensions'").get();
if (!hasDimensions) {
console.log('Creating dimensions table for v0.1.16+ features...');
this.db.exec(`
CREATE TABLE dimensions (
name TEXT PRIMARY KEY,
description TEXT,
is_priority INTEGER DEFAULT 0,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
`);
// Seed default locked dimensions
const defaultDimensions = ['research', 'ideas', 'projects', 'memory', 'preferences'];
const insertDimension = this.db.prepare(`
INSERT INTO dimensions (name, is_priority, updated_at)
VALUES (?, 1, datetime('now'))
ON CONFLICT(name) DO UPDATE SET is_priority = 1, updated_at = datetime('now')
`);
for (const dimension of defaultDimensions) {
try {
insertDimension.run(dimension);
} catch (e) {
console.warn(`Failed to seed dimension '${dimension}':`, e);
}
}
console.log('Dimensions table created and seeded with default locked dimensions');
} else {
// Check if existing dimensions table has description column
const dimensionCols = this.db.prepare('PRAGMA table_info(dimensions)').all() as Array<{ name: string }>;
const hasDescription = dimensionCols.some(col => col.name === 'description');
if (!hasDescription) {
console.log('Adding description column to existing dimensions table...');
try {
this.db.exec('ALTER TABLE dimensions ADD COLUMN description TEXT;');
console.log('Description column added to dimensions table');
} catch (e) {
console.warn('Failed to add description column to dimensions table:', e);
}
}
}
// 10) Final schema pass migrations (source canonicalization, event_date, dimensions.icon, drop dead columns)
// 9) Final schema pass migrations (source-first backfill, event_date, soft contexts, drop dimensions)
try {
let nodeCols2 = this.db.prepare('PRAGMA table_info(nodes)').all() as Array<{ name: string }>;
let nodeColNames = nodeCols2.map(c => c.name);
@@ -634,6 +867,12 @@ class SQLiteClient {
nodeColNames = nodeCols2.map(c => c.name);
}
if (!nodeColNames.includes('chunk_status')) {
this.db.exec("ALTER TABLE nodes ADD COLUMN chunk_status TEXT DEFAULT 'not_chunked';");
nodeCols2 = this.db.prepare('PRAGMA table_info(nodes)').all() as Array<{ name: string }>;
nodeColNames = nodeCols2.map(c => c.name);
}
if (nodeColNames.includes('source')) {
if (nodeColNames.includes('content')) {
this.db.exec(`
@@ -691,19 +930,121 @@ class SQLiteClient {
// Add event_date
if (!nodeColNames.includes('event_date')) {
this.db.exec('ALTER TABLE nodes ADD COLUMN event_date TEXT;');
// Backfill from metadata.published_date where available
// Backfill from metadata.published_date or metadata.source_metadata.published_date where available
try {
this.db.exec(`
UPDATE nodes SET event_date = json_extract(metadata, '$.published_date')
WHERE event_date IS NULL AND json_extract(metadata, '$.published_date') IS NOT NULL;
UPDATE nodes
SET event_date = COALESCE(
json_extract(metadata, '$.source_metadata.published_date'),
json_extract(metadata, '$.published_date')
)
WHERE event_date IS NULL
AND COALESCE(
json_extract(metadata, '$.source_metadata.published_date'),
json_extract(metadata, '$.published_date')
) IS NOT NULL;
`);
} catch {}
}
// Add dimensions.icon
const dimCols2 = this.db.prepare('PRAGMA table_info(dimensions)').all() as Array<{ name: string }>;
if (!dimCols2.some(c => c.name === 'icon')) {
this.db.exec('ALTER TABLE dimensions ADD COLUMN icon TEXT;');
if (!nodeColNames.includes('context_id')) {
this.db.exec('ALTER TABLE nodes ADD COLUMN context_id INTEGER REFERENCES contexts(id) ON DELETE SET NULL;');
}
const hasContexts = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='contexts'").get();
if (!hasContexts) {
this.db.exec(`
CREATE TABLE 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
);
`);
}
const contextCols = this.db.prepare('PRAGMA table_info(contexts)').all() as Array<{ name: string }>;
const ensureContextCol = (name: string, ddl: string) => {
if (!contextCols.some(col => col.name === name)) {
this.db.exec(ddl);
}
};
ensureContextCol('description', "ALTER TABLE contexts ADD COLUMN description TEXT NOT NULL DEFAULT '';");
ensureContextCol('icon', 'ALTER TABLE contexts ADD COLUMN icon TEXT;');
ensureContextCol('created_at', "ALTER TABLE contexts ADD COLUMN created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;");
ensureContextCol('updated_at', "ALTER TABLE contexts ADD COLUMN updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;");
this.db.exec(`
UPDATE contexts
SET description = COALESCE(NULLIF(TRIM(description), ''), name)
WHERE description IS NULL OR LENGTH(TRIM(description)) = 0;
`);
this.db.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_contexts_name_normalized
ON contexts(LOWER(TRIM(name)));
CREATE INDEX IF NOT EXISTS idx_nodes_context_id ON nodes(context_id);
`);
const hasLegacyDimensions = this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='dimensions'").get();
const hasLegacyNodeDimensions = this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='node_dimensions'").get();
this.db.exec(`
CREATE TABLE IF NOT EXISTS dimension_migration_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
migrated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
dimension_count INTEGER NOT NULL,
assignment_count INTEGER NOT NULL,
payload TEXT
);
`);
if (hasLegacyDimensions || hasLegacyNodeDimensions) {
const existingSnapshotCount = Number(
(this.db.prepare('SELECT COUNT(*) as count FROM dimension_migration_snapshots').get() as { count?: number } | undefined)?.count ?? 0
);
if (existingSnapshotCount === 0) {
const dimensionCount = hasLegacyDimensions
? Number((this.db.prepare('SELECT COUNT(*) as count FROM dimensions').get() as { count?: number } | undefined)?.count ?? 0)
: 0;
const assignmentCount = hasLegacyNodeDimensions
? Number((this.db.prepare('SELECT COUNT(*) as count FROM node_dimensions').get() as { count?: number } | undefined)?.count ?? 0)
: 0;
const payload = hasLegacyNodeDimensions
? (
this.db.prepare(`
SELECT COALESCE(
json_group_array(
json_object(
'node_id', nd.node_id,
'dimension', nd.dimension,
'description', d.description,
'icon', d.icon,
'is_priority', d.is_priority
)
),
'[]'
) AS payload
FROM node_dimensions nd
LEFT JOIN dimensions d ON d.name = nd.dimension
`).get() as { payload?: string } | undefined
)?.payload ?? '[]'
: '[]';
this.db.prepare(`
INSERT INTO dimension_migration_snapshots (dimension_count, assignment_count, payload)
VALUES (?, ?, ?)
`).run(dimensionCount, assignmentCount, payload);
}
this.db.exec(`
DROP INDEX IF EXISTS idx_dim_by_dimension;
DROP INDEX IF EXISTS idx_dim_by_node;
DROP TABLE IF EXISTS node_dimensions;
DROP TABLE IF EXISTS dimensions;
`);
}
// Drop dead columns (requires SQLite 3.35+)
@@ -742,7 +1083,11 @@ class SQLiteClient {
this.db.exec("INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild');");
}
} catch (ftsErr) {
console.warn('Failed to rebuild nodes_fts:', ftsErr);
if (this.isSqliteCorruptError(ftsErr)) {
this.disableNodesFts('existing nodes_fts is corrupt and could not be rebuilt', ftsErr);
} else {
console.warn('Failed to rebuild nodes_fts:', ftsErr);
}
}
} catch (schemaErr) {
console.warn('Final schema pass migration error:', schemaErr);
@@ -854,6 +1199,15 @@ class SQLiteClient {
public close(): void {
this.db.close();
}
private isSqliteCorruptError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
const sqliteError = error as Error & { code?: string };
return sqliteError.code === 'SQLITE_CORRUPT' || /database disk image is malformed/i.test(sqliteError.message || '');
}
}
// Export singleton instance (similar to PostgreSQL client interface)
@@ -1,124 +0,0 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { getSQLiteClient } from '@/services/database/sqlite-client';
export interface AutoContextSettings {
autoContextEnabled: boolean;
lastPinnedMigration?: string;
}
const SETTINGS_FILE = 'settings.json';
const DEFAULT_SETTINGS: AutoContextSettings = {
autoContextEnabled: false,
};
let bootstrapAttempted = false;
function resolveBaseConfigDir(): string {
const override = process.env.RAH_CONFIG_DIR;
if (override && override.trim().length > 0) {
return override;
}
const home = os.homedir();
if (process.platform === 'darwin') {
return path.join(home, 'Library', 'Application Support', 'RA-H');
}
if (process.platform === 'win32') {
const roaming = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
return path.join(roaming, 'RA-H');
}
const xdgConfig = process.env.XDG_CONFIG_HOME || path.join(home, '.config');
return path.join(xdgConfig, 'ra-h');
}
function getSettingsDir(): string {
return path.join(resolveBaseConfigDir(), 'config');
}
function getSettingsPath(): string {
return path.join(getSettingsDir(), SETTINGS_FILE);
}
function ensureSettingsDirExists(): void {
const dir = getSettingsDir();
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
function writeSettingsFile(settings: AutoContextSettings): AutoContextSettings {
ensureSettingsDirExists();
fs.writeFileSync(getSettingsPath(), JSON.stringify(settings, null, 2), 'utf-8');
return settings;
}
function bootstrapFromLegacyPins(): void {
if (bootstrapAttempted) return;
bootstrapAttempted = true;
const settingsPath = getSettingsPath();
if (fs.existsSync(settingsPath)) {
return;
}
try {
const db = getSQLiteClient();
const countRow = db
.query<{ count: number }>('SELECT COUNT(*) as count FROM nodes WHERE 1=0 /* /* is_pinned removed */ removed */')
.rows[0];
const pinnedCount = Number(countRow?.count ?? 0);
if (pinnedCount > 0) {
writeSettingsFile({
autoContextEnabled: true,
lastPinnedMigration: new Date().toISOString(),
});
}
} catch (error) {
console.warn('Auto-context pin bootstrap failed:', error);
}
}
export function getAutoContextSettings(): AutoContextSettings {
bootstrapFromLegacyPins();
const settingsPath = getSettingsPath();
try {
if (!fs.existsSync(settingsPath)) {
return { ...DEFAULT_SETTINGS };
}
const raw = fs.readFileSync(settingsPath, 'utf-8');
const parsed = JSON.parse(raw);
return {
...DEFAULT_SETTINGS,
...parsed,
autoContextEnabled: Boolean(parsed?.autoContextEnabled),
};
} catch (error) {
console.warn('Failed to read auto-context settings, using defaults:', error);
return { ...DEFAULT_SETTINGS };
}
}
export function updateAutoContextSettings(
partial: Partial<AutoContextSettings>
): AutoContextSettings {
const current = getAutoContextSettings();
const next: AutoContextSettings = {
...current,
...partial,
autoContextEnabled:
typeof partial.autoContextEnabled === 'boolean'
? partial.autoContextEnabled
: current.autoContextEnabled,
};
return writeSettingsFile(next);
}
export function setAutoContextEnabled(enabled: boolean): AutoContextSettings {
return updateAutoContextSettings({ autoContextEnabled: enabled });
}
+52 -60
View File
@@ -1,26 +1,24 @@
/**
* Node metadata embedding service for RA-H knowledge management system
* Embeds node metadata (title, content, dimensions, AI analysis) into nodes.embedding field
* Embeds node metadata (title, source, context, AI analysis) into nodes.embedding field
*/
import OpenAI from 'openai';
import { generateText } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import {
createDatabaseConnection,
getDbVectorCapability,
import {
createDatabaseConnection,
serializeFloat32Vector,
formatEmbeddingText,
batchProcess
batchProcess
} from './sqlite-vec';
import type { VectorCapability } from '@/services/database/sqlite-runtime';
interface NodeRecord {
id: number;
title: string;
source: string | null;
description: string | null;
dimensions_json: string;
context_name: string | null;
embedding?: Buffer | null;
embedding_updated_at?: string | null;
embedding_text?: string | null;
@@ -36,7 +34,6 @@ export class NodeEmbedder {
private openaiClient: OpenAI;
private openaiProvider: ReturnType<typeof createOpenAI>;
private db: ReturnType<typeof createDatabaseConnection>;
private readonly vectorCapability: VectorCapability;
private processedCount: number = 0;
private failedCount: number = 0;
@@ -45,25 +42,21 @@ export class NodeEmbedder {
if (!apiKey) {
throw new Error('OPENAI_API_KEY environment variable is not set');
}
this.openaiClient = new OpenAI({ apiKey });
this.openaiProvider = createOpenAI({ apiKey });
this.db = createDatabaseConnection();
this.vectorCapability = getDbVectorCapability(this.db);
}
/**
* Analyze node content with AI to extract insights
*/
private async analyzeNodeWithAI(node: NodeRecord): Promise<string> {
const dimensions = node.dimensions_json ? JSON.parse(node.dimensions_json) : [];
const dimensionsText = Array.isArray(dimensions) && dimensions.length ? dimensions.join(', ') : 'none';
const prompt = `Analyze this content and provide 2-3 key insights or themes in a concise paragraph (max 100 words):
Title: ${node.title}
Source: ${node.source || 'No source'}
Dimensions: ${dimensionsText}
Context: ${node.context_name || 'none'}
Focus on the main concepts, key relationships, and practical implications.`;
@@ -90,7 +83,7 @@ Focus on the main concepts, key relationships, and practical implications.`;
model: 'text-embedding-3-small',
input: text,
});
return response.data[0].embedding;
}
@@ -104,15 +97,12 @@ Focus on the main concepts, key relationships, and practical implications.`;
return;
}
// Parse dimensions from JSON string
const dimensions = node.dimensions_json ? JSON.parse(node.dimensions_json) : [];
// Create base embedding text
let embeddingText = formatEmbeddingText(
node.title,
node.source || '',
dimensions,
node.description
node.description,
node.context_name
);
// Add AI analysis if source exists
@@ -128,36 +118,41 @@ Focus on the main concepts, key relationships, and practical implications.`;
// Generate embedding
const embedding = await this.generateEmbedding(embeddingText);
const embeddingBlob = serializeFloat32Vector(embedding);
// Update database
const updateStmt = this.db.prepare(`
UPDATE nodes
SET embedding = ?,
embedding_updated_at = ?,
UPDATE nodes
SET embedding = ?,
embedding_updated_at = ?,
embedding_text = ?
WHERE id = ?
`);
const now = new Date().toISOString();
updateStmt.run(embeddingBlob, now, embeddingText, node.id);
if (this.vectorCapability.available) {
try {
const pkCol = 'node_id';
const deleteStmt = this.db.prepare(`DELETE FROM vec_nodes WHERE ${pkCol} = ?`);
deleteStmt.run(BigInt(node.id));
const vectorString = `[${embedding.join(',')}]`;
const insertStmt = this.db.prepare(`INSERT INTO vec_nodes (${pkCol}, embedding) VALUES (?, ?)`);
insertStmt.run(BigInt(node.id), vectorString);
} catch (vecError) {
console.warn(`Could not update vec_nodes for node ${node.id}:`, vecError);
}
// Update vec_nodes virtual table
try {
// Determine correct column name for primary key (node_id vs id)
// Use declared PK column from your DB schema (confirmed: node_id)
const pkCol = 'node_id';
// Delete existing entry if any
const deleteStmt = this.db.prepare(`DELETE FROM vec_nodes WHERE ${pkCol} = ?`);
deleteStmt.run(BigInt(node.id));
// Insert new entry (use bracketed string format compatible with sqlite-vec)
const vectorString = `[${embedding.join(',')}]`;
const insertStmt = this.db.prepare(`INSERT INTO vec_nodes (${pkCol}, embedding) VALUES (?, ?)`);
insertStmt.run(BigInt(node.id), vectorString);
} catch (vecError) {
console.warn(`Could not update vec_nodes for node ${node.id}:`, vecError);
// Continue - main embedding is still saved
}
this.processedCount++;
console.log(`✓ Embedded node ${node.id}: "${node.title}"`);
} catch (error) {
this.failedCount++;
console.error(`✗ Failed to embed node ${node.id}:`, error);
@@ -170,54 +165,51 @@ Focus on the main concepts, key relationships, and practical implications.`;
*/
async embedNodes(options: EmbedNodeOptions = {}): Promise<{ processed: number; failed: number }> {
const { nodeId, forceReEmbed = false, verbose = false } = options;
let query: string;
let params: any[] = [];
if (nodeId) {
// Single node
query = `
SELECT n.id, n.title, n.source, n.description,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
SELECT n.id, n.title, n.source, n.description, c.name as context_name,
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,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
SELECT n.id, n.title, n.source, n.description, c.name as context_name,
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,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
SELECT n.id, n.title, n.source, n.description, c.name as context_name,
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
`;
}
const stmt = this.db.prepare(query);
const nodes = stmt.all(...params) as NodeRecord[];
if (nodes.length === 0) {
console.log('No nodes to process');
return { processed: 0, failed: 0 };
}
console.log(`Processing ${nodes.length} nodes...`);
// Process in batches
await batchProcess(
nodes,
@@ -233,9 +225,9 @@ Focus on the main concepts, key relationships, and practical implications.`;
console.log(`Progress: ${processed}/${total} nodes`);
} : undefined
);
console.log(`\nComplete! Processed: ${this.processedCount}, Failed: ${this.failedCount}`);
return {
processed: this.processedCount,
failed: this.failedCount
@@ -254,15 +246,15 @@ Focus on the main concepts, key relationships, and practical implications.`;
* CLI interface for direct execution
*/
export async function runCLI(args: string[]): Promise<void> {
const nodeId = args.includes('--node-id')
const nodeId = args.includes('--node-id')
? parseInt(args[args.indexOf('--node-id') + 1])
: undefined;
const forceReEmbed = args.includes('--force');
const verbose = args.includes('--verbose');
const embedder = new NodeEmbedder();
try {
await embedder.embedNodes({ nodeId, forceReEmbed, verbose });
} finally {
+47 -19
View File
@@ -4,13 +4,9 @@
*/
import Database from 'better-sqlite3';
import {
ensureDatabaseDirectory,
getDatabasePath,
getDbVectorCapability,
getVecExtensionPath,
loadVecExtension,
} from '@/services/database/sqlite-runtime';
import path from 'path';
import os from 'os';
import { getDbVectorCapability as getVectorCapability } from '@/services/database/sqlite-runtime';
/**
* Serialize a float array to binary format for vec0 storage
@@ -35,24 +31,56 @@ export function deserializeFloat32Vector(blob: Buffer): number[] {
return vector;
}
/**
* Get SQLite database path from environment or default location
*/
export function getDatabasePath(): string {
const envPath = process.env.SQLITE_DB_PATH;
if (envPath) {
return envPath;
}
// Default path: ~/Library/Application Support/RA-H/db/rah.sqlite
const homeDir = os.homedir();
return path.join(homeDir, 'Library', 'Application Support', 'RA-H', 'db', 'rah.sqlite');
}
/**
* Get vec extension path from environment or default location
*/
export function getVecExtensionPath(): string {
const envPath = process.env.SQLITE_VEC_EXTENSION_PATH;
if (envPath) {
return envPath;
}
// Default path relative to project root
return path.join(process.cwd(), 'vendor', 'sqlite-extensions', 'vec0.dylib');
}
/**
* Create database connection with vec0 extension loaded
*/
export function createDatabaseConnection(): Database.Database {
const dbPath = getDatabasePath();
const vecPath = getVecExtensionPath();
ensureDatabaseDirectory(dbPath);
const db = new Database(dbPath);
const capability = loadVecExtension(db, vecPath);
if (!capability.available) {
console.warn(`Warning: ${capability.reason}`);
// Load vec0 extension
try {
db.loadExtension(vecPath);
} catch (error) {
console.error('Warning: Could not load vec0 extension:', error);
// Continue without vector support for non-vector operations
}
return db;
}
export { getDatabasePath, getDbVectorCapability, getVecExtensionPath };
export function getDbVectorCapability(db: Database.Database) {
return getVectorCapability(db);
}
/**
* Format embedding text for node metadata
@@ -60,12 +88,12 @@ export { getDatabasePath, getDbVectorCapability, getVecExtensionPath };
export function formatEmbeddingText(
title: string,
content: string,
dimensions: string[],
description?: string | null
description?: string | null,
contextName?: string | null
): string {
const descriptionText = description && description.trim() ? description.trim() : 'none';
const dimensionsText = dimensions.length > 0 ? dimensions.join(', ') : 'none';
return `Title: ${title}\n\nDescription: ${descriptionText}\n\nContent: ${content}\n\nDimensions: ${dimensionsText}`;
const contextText = contextName && contextName.trim() ? contextName.trim() : 'none';
return `Title: ${title}\n\nDescription: ${descriptionText}\n\nContent: ${content}\n\nContext: ${contextText}`;
}
/**
@@ -78,16 +106,16 @@ export async function batchProcess<T, R>(
onProgress?: (processed: number, total: number) => void
): Promise<R[]> {
const results: R[] = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, Math.min(i + batchSize, items.length));
const batchResults = await Promise.all(batch.map(processor));
results.push(...batchResults);
if (onProgress) {
onProgress(Math.min(i + batchSize, items.length), items.length);
}
}
return results;
}