refactor: align source-first schema surfaces

This commit is contained in:
“BeeRad”
2026-03-22 20:17:07 +11:00
parent 255377658b
commit b9af8dc385
21 changed files with 267 additions and 93 deletions
+11 -4
View File
@@ -134,7 +134,7 @@ function createRAHServer(): McpServer {
id: node.id, id: node.id,
title: node.title, title: node.title,
description: node.description ?? null, description: node.description ?? null,
source: node.source ?? node.notes ?? null, source: node.source ?? null,
link: node.link ?? null, link: node.link ?? null,
dimensions: node.dimensions || [], dimensions: node.dimensions || [],
metadata: node.metadata || {}, metadata: node.metadata || {},
@@ -309,22 +309,29 @@ function createRAHServer(): McpServer {
'rah_update_node', 'rah_update_node',
{ {
title: 'Update RA-H node', title: 'Update RA-H node',
description: 'Update an existing node. Content is APPENDED, dimensions are replaced.', description: 'Update an existing node. Source content is canonical and dimensions are replaced.',
inputSchema: { inputSchema: {
id: z.number().int().positive().describe('Node ID to update'), id: z.number().int().positive().describe('Node ID to update'),
updates: z.object({ updates: z.object({
title: z.string().optional().describe('New title'), title: z.string().optional().describe('New title'),
content: z.string().optional().describe('Content to APPEND'), content: z.string().optional().describe('Legacy alias for source'),
source: z.string().optional().describe('Canonical source text'),
link: z.string().optional().describe('New link'), link: z.string().optional().describe('New link'),
dimensions: z.array(z.string()).optional().describe('New dimensions (replaces existing)'), dimensions: z.array(z.string()).optional().describe('New dimensions (replaces existing)'),
}).describe('Fields to update'), }).describe('Fields to update'),
}, },
}, },
async ({ id, updates }) => { async ({ id, updates }) => {
const mappedUpdates = { ...updates } as Record<string, unknown>;
if (mappedUpdates.content !== undefined && mappedUpdates.source === undefined) {
mappedUpdates.source = mappedUpdates.content;
}
delete mappedUpdates.content;
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes/${id}`, { const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes/${id}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates), body: JSON.stringify(mappedUpdates),
}); });
const result = await response.json(); const result = await response.json();
@@ -85,7 +85,7 @@ export async function POST(
// Generate new description using the description service // Generate new description using the description service
const newDescription = await generateDescription({ const newDescription = await generateDescription({
title: node.title, title: node.title,
notes: node.source || node.description || undefined, source: node.source || node.description || undefined,
link: node.link || undefined, link: node.link || undefined,
metadata: enrichedMetadata, metadata: enrichedMetadata,
+1 -1
View File
@@ -108,7 +108,7 @@ export async function POST(request: NextRequest) {
try { try {
nodeDescription = await generateDescription({ nodeDescription = await generateDescription({
title: body.title, title: body.title,
notes: rawSource?.slice(0, 2000) || undefined, source: rawSource?.slice(0, 2000) || undefined,
link: body.link || undefined, link: body.link || undefined,
metadata: body.metadata, metadata: body.metadata,
dimensions: trimmedProvidedDimensions dimensions: trimmedProvidedDimensions
+7 -9
View File
@@ -351,8 +351,7 @@ async function main() {
'createNode', 'createNode',
{ {
title: 'Add RA-H node', title: 'Add RA-H node',
description: 'Create a new node. Always search first (queryNodes) to avoid duplicates. Title: max 160 chars, clear and descriptive. Description is REQUIRED and must be explicit about what the thing is and why it matters for contextual grounding. Use "link" ONLY for external content (URL, video, article) — omit for synthesis/ideas derived from existing nodes. "content" = your notes/analysis. "chunk" = verbatim source text. Assign 1-5 dimensions — call queryDimensions first to use existing ones.', description: 'Create a new node. Always search first (queryNodes) to avoid duplicates. Title: max 160 chars, clear and descriptive. Description is REQUIRED and must be explicit about what the thing is and why it matters for contextual grounding. Use "link" ONLY for external content (URL, video, article) — omit for synthesis/ideas derived from existing nodes. "source" = verbatim or canonical content for embedding. Legacy "content" and "chunk" are mapped to source for compatibility. Assign 1-5 dimensions — call queryDimensions first to use existing ones.',
// Note: MCP schema uses "content" for external API compat; mapped to "notes" internally
inputSchema: addNodeInputSchema inputSchema: addNodeInputSchema
}, },
async ({ title, content, source, link, description, dimensions, metadata, chunk }) => { async ({ title, content, source, link, description, dimensions, metadata, chunk }) => {
@@ -392,8 +391,7 @@ async function main() {
'queryNodes', 'queryNodes',
{ {
title: 'Search RA-H nodes', title: 'Search RA-H nodes',
description: 'Search nodes by keyword across title, description, and content fields. Multi-word queries find nodes containing all words (not exact phrases). Returns up to 25 results (default 10). Call before creating nodes to check for duplicates. Optionally filter by dimensions. NOT for searching source documents (transcripts, articles) — use searchContentEmbeddings for that.', description: 'Search nodes by keyword across title, description, and source fields. Multi-word queries find nodes containing all words (not exact phrases). Returns up to 25 results (default 10). Call before creating nodes to check for duplicates. Optionally filter by dimensions. NOT for searching source documents (transcripts, articles) — use searchContentEmbeddings for that.',
// Note: searches across title, description, and notes columns
inputSchema: searchNodesInputSchema inputSchema: searchNodesInputSchema
}, },
async ({ query: searchQuery, limit = 10, dimensions, created_after, created_before, event_after, event_before }) => { async ({ query: searchQuery, limit = 10, dimensions, created_after, created_before, event_after, event_before }) => {
@@ -560,13 +558,13 @@ async function main() {
for (const id of uniqueIds) { for (const id of uniqueIds) {
const node = nodeService.getNodeById(id); const node = nodeService.getNodeById(id);
if (node) { if (node) {
const rawChunk = node.source ?? node.chunk ?? null; const rawChunk = node.source ?? null;
const chunkTruncated = rawChunk ? rawChunk.length > CHUNK_LIMIT : false; const chunkTruncated = rawChunk ? rawChunk.length > CHUNK_LIMIT : false;
nodes.push({ nodes.push({
id: node.id, id: node.id,
title: node.title, title: node.title,
source: node.source ?? node.notes ?? null, source: node.source ?? null,
description: node.description ?? null, description: node.description ?? null,
link: node.link ?? null, link: node.link ?? null,
chunk: chunkTruncated ? rawChunk.substring(0, CHUNK_LIMIT) : rawChunk, chunk: chunkTruncated ? rawChunk.substring(0, CHUNK_LIMIT) : rawChunk,
@@ -595,7 +593,7 @@ async function main() {
'updateNode', 'updateNode',
{ {
title: 'Update RA-H node', title: 'Update RA-H node',
description: 'Update an existing node. Description is REQUIRED on every update and must explicitly state WHAT this thing is + WHY it matters for contextual grounding. Content is APPENDED to existing notes (not replaced). Dimensions are REPLACED entirely with the new array. Title, description, and link are overwritten. Call getNodesById first to verify current state before updating.', description: 'Update an existing node. Description is REQUIRED on every update and must explicitly state WHAT this thing is + WHY it matters for contextual grounding. Source content lives in "source". Legacy "content" and "chunk" are mapped to source for compatibility. Dimensions are REPLACED entirely with the new array. Title, description, and link are overwritten. Call getNodesById first to verify current state before updating.',
inputSchema: updateNodeInputSchema inputSchema: updateNodeInputSchema
}, },
async ({ id, updates }) => { async ({ id, updates }) => {
@@ -621,7 +619,7 @@ async function main() {
delete mappedUpdates.content; delete mappedUpdates.content;
delete mappedUpdates.chunk; delete mappedUpdates.chunk;
const node = nodeService.updateNode(id, mappedUpdates, { appendNotes: false }); const node = nodeService.updateNode(id, mappedUpdates);
return { return {
content: [{ type: 'text', text: `Updated node #${id}` }], content: [{ type: 'text', text: `Updated node #${id}` }],
@@ -916,7 +914,7 @@ async function main() {
const tableCheck = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chunks'").get(); const tableCheck = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chunks'").get();
if (!tableCheck) { if (!tableCheck) {
return { return {
content: [{ type: 'text', text: 'No chunks table found. Source content has not been chunked yet. Use getNodesById to read the raw chunk field instead.' }], content: [{ type: 'text', text: 'No chunks table found. Source content has not been chunked yet. Use getNodesById to read the raw source text instead.' }],
structuredContent: { count: 0, chunks: [], note: 'chunks table does not exist' } structuredContent: { count: 0, chunks: [], note: 'chunks table does not exist' }
}; };
} }
@@ -9,7 +9,7 @@ function getNodes(filters = {}) {
const { dimensions, search, limit = 100, offset = 0 } = filters; const { dimensions, search, limit = 100, offset = 0 } = filters;
let sql = ` let sql = `
SELECT n.id, n.title, n.description, n.source, n.notes, n.link, n.event_date, n.metadata, n.chunk, SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.created_at, n.updated_at, n.created_at, n.updated_at,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
@@ -70,7 +70,7 @@ function getNodes(filters = {}) {
*/ */
function getNodeById(id) { function getNodeById(id) {
const sql = ` const sql = `
SELECT n.id, n.title, n.description, n.source, n.notes, n.link, n.event_date, n.metadata, n.chunk, SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.created_at, n.updated_at, n.created_at, n.updated_at,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
@@ -164,8 +164,7 @@ function createNode(nodeData) {
* Source-first update path. * Source-first update path.
*/ */
function updateNode(id, updates, options = {}) { function updateNode(id, updates, options = {}) {
const { appendNotes = true } = options; const { title, description, source, link, event_date, dimensions, metadata } = updates;
const { title, description, source, notes, link, event_date, dimensions, chunk, metadata } = updates;
const now = new Date().toISOString(); const now = new Date().toISOString();
const db = getDb(); const db = getDb();
@@ -190,12 +189,6 @@ function updateNode(id, updates, options = {}) {
if (source !== undefined) { if (source !== undefined) {
setFields.push('source = ?'); setFields.push('source = ?');
params.push(source); params.push(source);
} else if (notes !== undefined) {
const nextSource = appendNotes && existing.source
? `${existing.source}\n\n${notes}`
: notes;
setFields.push('source = ?');
params.push(nextSource);
} }
if (link !== undefined) { if (link !== undefined) {
setFields.push('link = ?'); setFields.push('link = ?');
@@ -205,10 +198,6 @@ function updateNode(id, updates, options = {}) {
setFields.push('event_date = ?'); setFields.push('event_date = ?');
params.push(event_date); params.push(event_date);
} }
if (chunk !== undefined && source === undefined) {
setFields.push('source = ?');
params.push(chunk);
}
if (metadata !== undefined) { if (metadata !== undefined) {
setFields.push('metadata = ?'); setFields.push('metadata = ?');
params.push(JSON.stringify(metadata)); params.push(JSON.stringify(metadata));
@@ -50,13 +50,12 @@ function initDatabase() {
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
title TEXT, title TEXT,
description TEXT, description TEXT,
notes TEXT, source TEXT,
link TEXT, link TEXT,
event_date TEXT, event_date TEXT,
created_at TEXT, created_at TEXT,
updated_at TEXT, updated_at TEXT,
metadata TEXT, metadata TEXT,
chunk TEXT,
embedding BLOB, embedding BLOB,
embedding_updated_at TEXT, embedding_updated_at TEXT,
embedding_text TEXT, embedding_text TEXT,
@@ -113,6 +112,38 @@ function initDatabase() {
db.pragma('cache_size = 5000'); db.pragma('cache_size = 5000');
db.pragma('busy_timeout = 5000'); db.pragma('busy_timeout = 5000');
const nodeCols = db.prepare('PRAGMA table_info(nodes)').all().map(c => c.name);
if (!nodeCols.includes('source')) {
db.exec('ALTER TABLE nodes ADD COLUMN source TEXT;');
}
if (nodeCols.includes('content')) {
db.exec(`
UPDATE nodes
SET source = content
WHERE (source IS NULL OR LENGTH(TRIM(source)) = 0)
AND content IS NOT NULL
AND LENGTH(TRIM(content)) > 0;
`);
}
if (nodeCols.includes('notes')) {
db.exec(`
UPDATE nodes
SET source = notes
WHERE (source IS NULL OR LENGTH(TRIM(source)) = 0)
AND notes IS NOT NULL
AND LENGTH(TRIM(notes)) > 0;
`);
}
if (nodeCols.includes('chunk')) {
db.exec(`
UPDATE nodes
SET source = chunk
WHERE (source IS NULL OR LENGTH(TRIM(source)) = 0)
AND chunk IS NOT NULL
AND LENGTH(TRIM(chunk)) > 0;
`);
}
const edgeCols = db.prepare('PRAGMA table_info(edges)').all().map(c => c.name); const edgeCols = db.prepare('PRAGMA table_info(edges)').all().map(c => c.name);
if (!edgeCols.includes('explanation')) { if (!edgeCols.includes('explanation')) {
db.exec('ALTER TABLE edges ADD COLUMN explanation TEXT;'); db.exec('ALTER TABLE edges ADD COLUMN explanation TEXT;');
+3 -3
View File
@@ -422,7 +422,7 @@ mcpServer.registerTool(
nodes: nodes.map((node) => ({ nodes: nodes.map((node) => ({
id: node.id, id: node.id,
title: node.title, title: node.title,
source: node.source ?? node.notes ?? null, source: node.source ?? null,
description: node.description ?? null, description: node.description ?? null,
link: node.link ?? null, link: node.link ?? null,
dimensions: node.dimensions || [], dimensions: node.dimensions || [],
@@ -496,7 +496,7 @@ mcpServer.registerTool(
nodes.push({ nodes.push({
id: result.node.id, id: result.node.id,
title: result.node.title, title: result.node.title,
source: result.node.source ?? result.node.notes ?? null, source: result.node.source ?? null,
link: result.node.link ?? null, link: result.node.link ?? null,
dimensions: result.node.dimensions || [], dimensions: result.node.dimensions || [],
updated_at: result.node.updated_at updated_at: result.node.updated_at
@@ -729,7 +729,7 @@ mcpServer.registerTool(
results: results.map(r => ({ results: results.map(r => ({
nodeId: r.node_id || r.nodeId || r.id, nodeId: r.node_id || r.nodeId || r.id,
title: r.title || 'Untitled', title: r.title || 'Untitled',
chunkPreview: (r.chunk || r.notes || '').slice(0, 200), chunkPreview: (r.source || '').slice(0, 200),
similarity: r.similarity || r.score || 0 similarity: r.similarity || r.score || 0
})) }))
} }
+3 -3
View File
@@ -393,7 +393,7 @@ server.registerTool(
nodes: nodes.map((node) => ({ nodes: nodes.map((node) => ({
id: node.id, id: node.id,
title: node.title, title: node.title,
source: node.source ?? node.notes ?? null, source: node.source ?? null,
description: node.description ?? null, description: node.description ?? null,
link: node.link ?? null, link: node.link ?? null,
dimensions: node.dimensions || [], dimensions: node.dimensions || [],
@@ -467,7 +467,7 @@ server.registerTool(
nodes.push({ nodes.push({
id: result.node.id, id: result.node.id,
title: result.node.title, title: result.node.title,
source: result.node.source ?? result.node.notes ?? null, source: result.node.source ?? null,
link: result.node.link ?? null, link: result.node.link ?? null,
dimensions: result.node.dimensions || [], dimensions: result.node.dimensions || [],
updated_at: result.node.updated_at updated_at: result.node.updated_at
@@ -700,7 +700,7 @@ server.registerTool(
results: results.map(r => ({ results: results.map(r => ({
nodeId: r.node_id || r.nodeId || r.id, nodeId: r.node_id || r.nodeId || r.id,
title: r.title || 'Untitled', title: r.title || 'Untitled',
chunkPreview: (r.chunk || r.notes || '').slice(0, 200), chunkPreview: (r.source || '').slice(0, 200),
similarity: r.similarity || r.score || 0 similarity: r.similarity || r.score || 0
})) }))
} }
+2 -2
View File
@@ -2,7 +2,7 @@
> Connect Claude Code and other AI assistants to your knowledge base. > Connect Claude Code and other AI assistants to your knowledge base.
**How it works:** RA-OS includes an MCP (Model Context Protocol) server. This lets any MCP-compatible assistant — like Claude Code — search your notes, add new knowledge, and manage your knowledge graph. Everything stays local. **How it works:** RA-OS includes an MCP (Model Context Protocol) server. This lets any MCP-compatible assistant — like Claude Code — search your knowledge graph, add new knowledge, and manage your graph. Everything stays local.
--- ---
@@ -77,7 +77,7 @@ If you want real-time UI updates when nodes are created:
| Tool | Description | | Tool | Description |
|------|-------------| |------|-------------|
| `getContext` | Get graph overview — stats, hub nodes, dimensions, recent activity. Called first automatically. | | `getContext` | Get graph overview — stats, hub nodes, dimensions, recent activity. Called first automatically. |
| `createNode` | Create a new node (title/content/dimensions) | | `createNode` | Create a new node (title/source/dimensions) |
| `queryNodes` | Search existing nodes by keyword | | `queryNodes` | Search existing nodes by keyword |
| `updateNode` | Update an existing node | | `updateNode` | Update an existing node |
| `getNodesById` | Get nodes by ID | | `getNodesById` | Get nodes by ID |
+60 -1
View File
@@ -287,6 +287,65 @@ if has_table nodes; then
# is_pinned removed in final schema pass # is_pinned removed in final schema pass
fi fi
if has_table nodes && has_col nodes source; then
if has_col nodes content; then
echo "Backfilling nodes.source from nodes.content"
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
UPDATE nodes
SET source = content,
chunk_status = 'not_chunked'
WHERE (source IS NULL OR LENGTH(TRIM(source)) = 0)
AND content IS NOT NULL
AND LENGTH(TRIM(content)) > 0;
SQL
fi
if has_col nodes notes; then
echo "Backfilling nodes.source from nodes.notes"
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
UPDATE nodes
SET source = notes,
chunk_status = 'not_chunked'
WHERE (source IS NULL OR LENGTH(TRIM(source)) = 0)
AND notes IS NOT NULL
AND LENGTH(TRIM(notes)) > 0;
SQL
fi
if has_col nodes chunk; then
echo "Backfilling nodes.source from nodes.chunk"
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
UPDATE nodes
SET source = chunk,
chunk_status = 'not_chunked'
WHERE (source IS NULL OR LENGTH(TRIM(source)) = 0)
AND chunk IS NOT NULL
AND LENGTH(TRIM(chunk)) > 0;
SQL
fi
echo "Filling empty nodes.source from title/description fallback"
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
UPDATE nodes
SET source = title || CASE
WHEN description IS NOT NULL AND LENGTH(TRIM(description)) > 0
THEN char(10) || char(10) || description
ELSE ''
END,
chunk_status = 'not_chunked'
WHERE source IS NULL OR LENGTH(TRIM(source)) = 0;
SQL
echo "Marking nodes with source for rechunking"
"$SQLITE_BIN" "$DB_PATH" <<'SQL'
UPDATE nodes
SET chunk_status = 'not_chunked'
WHERE source IS NOT NULL
AND LENGTH(TRIM(source)) > 0
AND (chunk_status IS NULL OR chunk_status != 'chunked');
SQL
fi
if has_table chunks; then if has_table chunks; then
if ! has_col chunks embedding_type; then if ! has_col chunks embedding_type; then
echo "Adding chunks.embedding_type" echo "Adding chunks.embedding_type"
@@ -329,7 +388,7 @@ CREATE VIEW nodes_v AS
SELECT n.id, SELECT n.id,
n.title, n.title,
n.description, n.description,
n.notes, n.source,
n.link, n.link,
n.event_date, n.event_date,
n.metadata, n.metadata,
+84
View File
@@ -248,6 +248,90 @@ function ensureCoreSchema(db) {
'[{"id":"p_seed_0","name":"Summary of Focus","content":"Summarize the primary focused node clearly. Include 35 key points and cite [NODE:id:\\"title\\"]."},{"id":"p_seed_1","name":"Next Steps","content":"Propose 3 concrete next actions based on the focused nodes with references to [NODE:id:\\"title\\"]."}]' '[{"id":"p_seed_0","name":"Summary of Focus","content":"Summarize the primary focused node clearly. Include 35 key points and cite [NODE:id:\\"title\\"]."},{"id":"p_seed_1","name":"Next Steps","content":"Propose 3 concrete next actions based on the focused nodes with references to [NODE:id:\\"title\\"]."}]'
); );
} }
const nodeCols = db.prepare('PRAGMA table_info(nodes)').all().map(col => col.name);
const hasNodeCol = (name) => nodeCols.includes(name);
if (!hasNodeCol('description')) {
db.exec('ALTER TABLE nodes ADD COLUMN description TEXT;');
}
if (!hasNodeCol('metadata')) {
db.exec('ALTER TABLE nodes ADD COLUMN metadata TEXT;');
}
if (!hasNodeCol('source')) {
db.exec('ALTER TABLE nodes ADD COLUMN source TEXT;');
}
if (!hasNodeCol('event_date')) {
db.exec('ALTER TABLE nodes ADD COLUMN event_date TEXT;');
}
if (hasNodeCol('content')) {
db.exec(`
UPDATE nodes
SET source = content,
chunk_status = 'not_chunked'
WHERE (source IS NULL OR LENGTH(TRIM(source)) = 0)
AND content IS NOT NULL
AND LENGTH(TRIM(content)) > 0;
`);
}
if (hasNodeCol('notes')) {
db.exec(`
UPDATE nodes
SET source = notes,
chunk_status = 'not_chunked'
WHERE (source IS NULL OR LENGTH(TRIM(source)) = 0)
AND notes IS NOT NULL
AND LENGTH(TRIM(notes)) > 0;
`);
}
if (hasNodeCol('chunk')) {
db.exec(`
UPDATE nodes
SET source = chunk,
chunk_status = 'not_chunked'
WHERE (source IS NULL OR LENGTH(TRIM(source)) = 0)
AND chunk IS NOT NULL
AND LENGTH(TRIM(chunk)) > 0;
`);
}
db.exec(`
UPDATE nodes
SET source = title || CASE
WHEN description IS NOT NULL AND LENGTH(TRIM(description)) > 0
THEN char(10) || char(10) || description
ELSE ''
END,
chunk_status = 'not_chunked'
WHERE source IS NULL OR LENGTH(TRIM(source)) = 0;
`);
db.exec(`
UPDATE nodes
SET chunk_status = 'not_chunked'
WHERE source IS NOT NULL
AND LENGTH(TRIM(source)) > 0
AND (chunk_status IS NULL OR chunk_status != 'chunked');
`);
db.exec('DROP VIEW IF EXISTS nodes_v;');
db.exec(`
CREATE VIEW nodes_v AS
SELECT n.id,
n.title,
n.description,
n.source,
n.link,
n.event_date,
n.metadata,
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
FROM nodes n;
`);
} }
function tryInitVectorTables(db, dbPath) { function tryInitVectorTables(db, dbPath) {
+2 -2
View File
@@ -291,7 +291,7 @@ export default function FocusPanel({
}; };
const startSourceEdit = () => { const startSourceEdit = () => {
setSourceEditValue(currentNode?.source || currentNode?.chunk || ''); setSourceEditValue(currentNode?.source || '');
setSourceEditMode(true); setSourceEditMode(true);
}; };
@@ -598,7 +598,7 @@ export default function FocusPanel({
}; };
const renderSourceSection = () => { const renderSourceSection = () => {
const sourceContent = currentNode?.source || currentNode?.chunk || ''; const sourceContent = currentNode?.source || '';
return ( return (
<section <section
@@ -235,7 +235,6 @@ export default function ThreePanelLayout() {
title: node.title, title: node.title,
link: node.link, link: node.link,
source: node.source, source: node.source,
notes: node.notes,
dimensions: node.dimensions, dimensions: node.dimensions,
created_at: node.created_at, created_at: node.created_at,
updated_at: node.updated_at, updated_at: node.updated_at,
+3 -3
View File
@@ -483,11 +483,11 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
</div> </div>
</td> </td>
{/* Notes */} {/* Source */}
<td style={tdStyle()}> <td style={tdStyle()}>
<div style={truncCell}> <div style={truncCell}>
<span style={{ fontSize: '11px', color: 'var(--rah-text-soft)' }}> <span style={{ fontSize: '11px', color: 'var(--rah-text-soft)' }}>
{(node.source || node.notes) ? (node.source || node.notes || '').slice(0, 120) : '\u2014'} {node.source ? node.source.slice(0, 120) : '\u2014'}
</span> </span>
</div> </div>
</td> </td>
@@ -576,7 +576,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
<td style={tdStyle()}> <td style={tdStyle()}>
<div style={truncCell}> <div style={truncCell}>
<span style={{ fontSize: '10px', color: 'var(--rah-text-muted)' }}> <span style={{ fontSize: '10px', color: 'var(--rah-text-muted)' }}>
{(node.source || node.chunk) ? (node.source || node.chunk || '').slice(0, 100) : '\u2014'} {node.source ? node.source.slice(0, 100) : '\u2014'}
</span> </span>
</div> </div>
</td> </td>
+2 -2
View File
@@ -104,7 +104,7 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
</div> </div>
{/* Description or Content Preview */} {/* Description or Content Preview */}
{(node.description || node.source || node.notes) && ( {(node.description || node.source) && (
<div style={{ <div style={{
flex: 1, flex: 1,
fontSize: '11px', fontSize: '11px',
@@ -116,7 +116,7 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
WebkitBoxOrient: 'vertical', WebkitBoxOrient: 'vertical',
marginBottom: '10px' marginBottom: '10px'
}}> }}>
{node.description || truncateContent(node.source || node.notes)} {node.description || truncateContent(node.source)}
</div> </div>
)} )}
+2 -2
View File
@@ -105,7 +105,7 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
</div> </div>
{/* Description or Content Preview */} {/* Description or Content Preview */}
{(node.description || node.source || node.notes) && ( {(node.description || node.source) && (
<div style={{ <div style={{
fontSize: '12px', fontSize: '12px',
color: '#666', color: '#666',
@@ -116,7 +116,7 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
WebkitBoxOrient: 'vertical', WebkitBoxOrient: 'vertical',
overflow: 'hidden' overflow: 'hidden'
}}> }}>
{node.description || truncateContent(node.source || node.notes)} {node.description || truncateContent(node.source)}
</div> </div>
)} )}
+4 -4
View File
@@ -4,7 +4,7 @@ import { hasValidOpenAiKey } from '../storage/apiKeys';
export interface DescriptionInput { export interface DescriptionInput {
title: string; title: string;
notes?: string; source?: string;
link?: string; link?: string;
metadata?: { metadata?: {
source?: string; source?: string;
@@ -61,7 +61,7 @@ export async function generateDescription(input: DescriptionInput): Promise<stri
} }
// Fast path: skip AI for very short inputs (likely just notes) // Fast path: skip AI for very short inputs (likely just notes)
if (!input.notes && !input.link && input.title.length < 30) { if (!input.source && !input.link && input.title.length < 30) {
console.log(`[DescriptionService] Short input, using fallback for: "${input.title}"`); console.log(`[DescriptionService] Short input, using fallback for: "${input.title}"`);
return generateFallbackDescription(input); return generateFallbackDescription(input);
} }
@@ -134,8 +134,8 @@ function buildDescriptionPrompt(input: DescriptionInput): string {
if (publisherHint) lines.push(`Publisher hint: ${publisherHint}`); if (publisherHint) lines.push(`Publisher hint: ${publisherHint}`);
lines.push(`Likely user-authored: ${likelyUserAuthored ? 'yes' : 'no'}`); lines.push(`Likely user-authored: ${likelyUserAuthored ? 'yes' : 'no'}`);
const contentPreview = input.notes?.slice(0, 800) || ''; const contentPreview = input.source?.slice(0, 800) || '';
if (contentPreview) lines.push(`Notes: ${contentPreview}${input.notes && input.notes.length > 800 ? '...' : ''}`); if (contentPreview) lines.push(`Source excerpt: ${contentPreview}${input.source && input.source.length > 800 ? '...' : ''}`);
return `Write a description for this knowledge node. Max 280 characters. return `Write a description for this knowledge node. Max 280 characters.
+6 -6
View File
@@ -138,7 +138,7 @@ export class NodeService {
// Use nodes_v view for array-like dimensions behavior (exclude embedding BLOB for performance) // Use nodes_v view for array-like dimensions behavior (exclude embedding BLOB for performance)
let query = ` let query = `
SELECT n.id, n.title, n.description, n.source, n.notes, n.link, n.event_date, n.metadata, n.chunk, 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.chunk_status, n.embedding_updated_at, n.embedding_text,
n.created_at, n.updated_at, n.created_at, n.updated_at,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
@@ -252,7 +252,7 @@ export class NodeService {
private async getNodeByIdSQLite(id: number): Promise<Node | null> { private async getNodeByIdSQLite(id: number): Promise<Node | null> {
const sqlite = getSQLiteClient(); const sqlite = getSQLiteClient();
const query = ` const query = `
SELECT n.id, n.title, n.description, n.source, n.notes, n.link, n.event_date, n.metadata, n.chunk, 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.chunk_status, n.embedding_updated_at, n.embedding_text,
n.created_at, n.updated_at, n.created_at, n.updated_at,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
@@ -589,7 +589,7 @@ export class NodeService {
WHERE nodes_fts MATCH ? WHERE nodes_fts MATCH ?
LIMIT ? LIMIT ?
) )
SELECT n.id, n.title, n.description, n.source, n.notes, n.link, n.event_date, n.metadata, n.chunk, 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.chunk_status, n.embedding_updated_at, n.embedding_text,
n.created_at, n.updated_at, n.created_at, n.updated_at,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
@@ -618,7 +618,7 @@ export class NodeService {
const words = search.split(/\s+/).filter(Boolean); const words = search.split(/\s+/).filter(Boolean);
const { clauses, params } = this.buildNodeFilterClauses(filters); const { clauses, params } = this.buildNodeFilterClauses(filters);
let query = ` let query = `
SELECT n.id, n.title, n.description, n.source, n.notes, n.link, n.event_date, n.metadata, n.chunk, 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.chunk_status, n.embedding_updated_at, n.embedding_text,
n.created_at, n.updated_at, n.created_at, n.updated_at,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
@@ -663,7 +663,7 @@ export class NodeService {
const { clauses, params } = this.buildNodeFilterClauses(filters); const { clauses, params } = this.buildNodeFilterClauses(filters);
let query = ` let query = `
SELECT n.id, n.title, n.description, n.source, n.notes, n.link, n.event_date, n.metadata, n.chunk, 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.chunk_status, n.embedding_updated_at, n.embedding_text,
n.created_at, n.updated_at, n.created_at, n.updated_at,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
@@ -737,7 +737,7 @@ export class NodeService {
ORDER BY distance ORDER BY distance
LIMIT ? LIMIT ?
) )
SELECT n.id, n.title, n.description, n.source, n.notes, n.link, n.event_date, n.metadata, n.chunk, 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.chunk_status, n.embedding_updated_at, n.embedding_text,
n.created_at, n.updated_at, n.created_at, n.updated_at,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
+32 -24
View File
@@ -616,19 +616,11 @@ class SQLiteClient {
} }
} }
// 10) Final schema pass migrations (content→notes, event_date, dimensions.icon, drop dead columns) // 10) Final schema pass migrations (source canonicalization, event_date, dimensions.icon, drop dead columns)
try { try {
let nodeCols2 = this.db.prepare('PRAGMA table_info(nodes)').all() as Array<{ name: string }>; let nodeCols2 = this.db.prepare('PRAGMA table_info(nodes)').all() as Array<{ name: string }>;
let nodeColNames = nodeCols2.map(c => c.name); let nodeColNames = nodeCols2.map(c => c.name);
// Rename content → notes (additive first)
if (nodeColNames.includes('content') && !nodeColNames.includes('notes')) {
console.log('Migrating nodes.content → nodes.notes...');
this.db.exec('ALTER TABLE nodes RENAME COLUMN content TO notes;');
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('source')) {
console.log('Adding nodes.source column...'); console.log('Adding nodes.source column...');
this.db.exec('ALTER TABLE nodes ADD COLUMN source TEXT;'); this.db.exec('ALTER TABLE nodes ADD COLUMN source TEXT;');
@@ -637,22 +629,38 @@ class SQLiteClient {
} }
if (nodeColNames.includes('source')) { if (nodeColNames.includes('source')) {
this.db.exec(` if (nodeColNames.includes('content')) {
UPDATE nodes this.db.exec(`
SET source = chunk UPDATE nodes
WHERE (source IS NULL OR LENGTH(TRIM(source)) = 0) SET source = content,
AND chunk IS NOT NULL chunk_status = 'not_chunked'
AND LENGTH(TRIM(chunk)) > 0; WHERE (source IS NULL OR LENGTH(TRIM(source)) = 0)
`); AND content IS NOT NULL
AND LENGTH(TRIM(content)) > 0;
`);
}
this.db.exec(` if (nodeColNames.includes('notes')) {
UPDATE nodes this.db.exec(`
SET source = notes, UPDATE nodes
chunk_status = 'not_chunked' SET source = notes,
WHERE (source IS NULL OR LENGTH(TRIM(source)) = 0) chunk_status = 'not_chunked'
AND notes IS NOT NULL WHERE (source IS NULL OR LENGTH(TRIM(source)) = 0)
AND LENGTH(TRIM(notes)) > 0; AND notes IS NOT NULL
`); AND LENGTH(TRIM(notes)) > 0;
`);
}
if (nodeColNames.includes('chunk')) {
this.db.exec(`
UPDATE nodes
SET source = chunk,
chunk_status = 'not_chunked'
WHERE (source IS NULL OR LENGTH(TRIM(source)) = 0)
AND chunk IS NOT NULL
AND LENGTH(TRIM(chunk)) > 0;
`);
}
this.db.exec(` this.db.exec(`
UPDATE nodes UPDATE nodes
+6 -7
View File
@@ -19,7 +19,6 @@ interface NodeRecord {
id: number; id: number;
title: string; title: string;
source: string | null; source: string | null;
notes: string | null;
description: string | null; description: string | null;
dimensions_json: string; dimensions_json: string;
embedding?: Buffer | null; embedding?: Buffer | null;
@@ -63,7 +62,7 @@ export class NodeEmbedder {
const prompt = `Analyze this content and provide 2-3 key insights or themes in a concise paragraph (max 100 words): const prompt = `Analyze this content and provide 2-3 key insights or themes in a concise paragraph (max 100 words):
Title: ${node.title} Title: ${node.title}
Source: ${node.source || node.notes || 'No source'} Source: ${node.source || 'No source'}
Dimensions: ${dimensionsText} Dimensions: ${dimensionsText}
Focus on the main concepts, key relationships, and practical implications.`; Focus on the main concepts, key relationships, and practical implications.`;
@@ -111,13 +110,13 @@ Focus on the main concepts, key relationships, and practical implications.`;
// Create base embedding text // Create base embedding text
let embeddingText = formatEmbeddingText( let embeddingText = formatEmbeddingText(
node.title, node.title,
node.source || node.notes || '', node.source || '',
dimensions, dimensions,
node.description node.description
); );
// Add AI analysis if source exists // Add AI analysis if source exists
const sourceText = node.source || node.notes || ''; const sourceText = node.source || '';
if (sourceText.trim().length > 0) { if (sourceText.trim().length > 0) {
const analysis = await this.analyzeNodeWithAI(node); const analysis = await this.analyzeNodeWithAI(node);
if (analysis) { if (analysis) {
@@ -178,7 +177,7 @@ Focus on the main concepts, key relationships, and practical implications.`;
if (nodeId) { if (nodeId) {
// Single node // Single node
query = ` query = `
SELECT n.id, n.title, n.source, n.notes, n.description, SELECT n.id, n.title, n.source, n.description,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json, FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
n.embedding, n.embedding_updated_at n.embedding, n.embedding_updated_at
@@ -189,7 +188,7 @@ Focus on the main concepts, key relationships, and practical implications.`;
} else if (forceReEmbed) { } else if (forceReEmbed) {
// All nodes // All nodes
query = ` query = `
SELECT n.id, n.title, n.source, n.notes, n.description, SELECT n.id, n.title, n.source, n.description,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json, FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
n.embedding, n.embedding_updated_at n.embedding, n.embedding_updated_at
@@ -199,7 +198,7 @@ Focus on the main concepts, key relationships, and practical implications.`;
} else { } else {
// Only nodes without embeddings // Only nodes without embeddings
query = ` query = `
SELECT n.id, n.title, n.source, n.notes, n.description, SELECT n.id, n.title, n.source, n.description,
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json, FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
n.embedding, n.embedding_updated_at n.embedding, n.embedding_updated_at
+2 -2
View File
@@ -48,8 +48,8 @@ export const queryDimensionNodesTool = tool({
event_date: node.event_date ?? null, event_date: node.event_date ?? null,
}; };
if (includeContent && (node.source || node.notes)) { if (includeContent && node.source) {
const previewSource = node.source || node.notes || ''; const previewSource = node.source;
// Truncate to ~100 chars // Truncate to ~100 chars
formatted.sourcePreview = previewSource.length > 100 formatted.sourcePreview = previewSource.length > 100
? previewSource.substring(0, 100) + '...' ? previewSource.substring(0, 100) + '...'