feat: sync open-source context and capsule removal

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

Generated with Codex
This commit is contained in:
“BeeRad”
2026-04-17 12:34:51 +10:00
parent 97eeb0789f
commit 07754f5030
87 changed files with 2688 additions and 4861 deletions
+8 -6
View File
@@ -2,6 +2,7 @@ import { tool } from 'ai';
import { z } from 'zod';
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
import { getInternalAuthHeaders } from '@/services/auth/internalAuth';
function extractTextFromMessageContent(content: unknown): string {
if (typeof content === 'string') {
@@ -56,14 +57,13 @@ function inferSourceFromContext(params: { title: string; description?: string; s
}
export const createNodeTool = tool({
description: 'Create a node after you have already decided this should be a net-new write. If the user explicitly asked to save or import something and duplicate/update checks are complete, write immediately. If you are only suggesting a save, propose the node first and wait for confirmation. Leave context blank by default. Only set context when the user explicitly wants one and it is clear and useful; when that happens, use context_name rather than a numeric ID. Focus on a clean title, a strong natural description that says what the thing is, preserved source text, and the right metadata. When the node comes from the user\'s own idea, note, or dictated thought, preserve their actual wording in source with only minimal cleanup instead of flattening it into a summary. Do not block creation if the description is incomplete. If the description framing is materially inferred, create the node first and then invite one concise user correction pass.',
description: 'Create a node after you have already decided this should be a net-new write. If the user explicitly asked to save or import something and duplicate/update checks are complete, write immediately. If you are only suggesting a save, propose the node first and wait for confirmation. Focus on a clean title, a strong natural description that says what the thing is, preserved source text, and the right metadata. When the node comes from the user\'s own idea, note, or dictated thought, preserve their actual wording in source with only minimal cleanup instead of flattening it into a summary. Do not block creation if the description is incomplete. If the description framing is materially inferred, create the node first and then invite one concise user correction pass.',
inputSchema: z.object({
title: z.string().describe('The title of the node'),
description: z.string().max(500).optional().describe('Optional natural description. If you have enough context, describe what this is, why it belongs in Brad\'s graph, and its current workflow status in normal prose. Do not use labels like WHAT:, WHY:, or STATUS:.'),
source: z.string().optional().describe('Canonical source content for embedding. For external content, store the actual transcript/article/document text. For user-authored ideas or dictated notes, store the user\'s original wording as fully as possible with only minimal cleanup such as obvious whitespace or transcription artifacts. Do not replace raw user thinking with a thin summary.'),
link: z.string().optional().describe('A URL link to the source'),
event_date: z.string().optional().describe('When the thing actually happened (ISO 8601). Not when it was added to the graph.'),
context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants this node assigned to a known context.'),
metadata: z.record(z.any()).optional().describe('Optional node metadata. Use canonical keys when known: type, state, captured_method, captured_by, and source_metadata. Source-specific facts belong inside source_metadata.')
}).passthrough(),
execute: async (params, context) => {
@@ -74,12 +74,12 @@ export const createNodeTool = tool({
// Call the nodes API endpoint
const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: getInternalAuthHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ ...params, source: canonicalSource ?? params.source })
});
const result = await response.json();
if (!response.ok) {
return {
success: false,
@@ -88,6 +88,7 @@ export const createNodeTool = tool({
};
}
// Format the created node for chat display
const formattedDisplay = formatNodeForChat({
id: result.data.id,
title: result.data.title,
@@ -97,9 +98,9 @@ export const createNodeTool = tool({
success: true,
data: {
...result.data,
formatted_display: formattedDisplay,
formatted_display: formattedDisplay
},
message: `Created: ${formattedDisplay}`
message: `Created node ${formattedDisplay}`
};
} catch (error) {
return {
@@ -111,4 +112,5 @@ export const createNodeTool = tool({
}
});
// Legacy export for backwards compatibility
export const createItemTool = createNodeTool;
-2
View File
@@ -36,8 +36,6 @@ export const getNodesByIdTool = tool({
title: node.title,
link: node.link,
event_date: node.event_date ?? null,
context_id: node.context_id ?? null,
context: node.context ?? null,
chunk_status: node.chunk_status || 'unknown',
created_at: node.created_at,
updated_at: node.updated_at,
-104
View File
@@ -1,104 +0,0 @@
import { tool } from 'ai';
import { z } from 'zod';
import { contextService } from '@/services/database/contextService';
type QueryContextFilters = {
id?: number;
name?: string;
search?: string;
limit?: number;
includeNodes?: boolean;
};
function matchesSearch(value: string | null | undefined, search: string): boolean {
if (!value) return false;
return value.toLowerCase().includes(search);
}
export const queryContextsTool = tool({
description: 'List and inspect contexts. Use this to discover available primary scopes before filtering nodes or assigning node context.',
inputSchema: z.object({
filters: z.object({
id: z.number().int().positive().describe('Exact context ID lookup.').optional(),
name: z.string().describe('Exact context name lookup.').optional(),
search: z.string().describe('Case-insensitive search across context names and descriptions.').optional(),
limit: z.number().min(1).max(100).default(50).describe('Maximum number of contexts to return.').optional(),
includeNodes: z.boolean().default(false).describe('Include the node list for an exact single-context lookup.').optional(),
}).optional(),
}),
execute: async ({ filters = {} }: { filters?: QueryContextFilters }) => {
try {
const limit = filters.limit || 50;
const normalizedName = filters.name?.trim();
const normalizedSearch = filters.search?.trim().toLowerCase();
let contexts = await contextService.listContexts();
if (filters.id !== undefined) {
contexts = contexts.filter((context) => context.id === filters.id);
}
if (normalizedName) {
contexts = contexts.filter((context) => context.name.toLowerCase() === normalizedName.toLowerCase());
}
if (normalizedSearch) {
contexts = contexts.filter((context) =>
matchesSearch(context.name, normalizedSearch) ||
matchesSearch(context.description, normalizedSearch)
);
}
const limitedContexts = contexts.slice(0, limit);
const canIncludeNodes =
filters.includeNodes === true &&
limitedContexts.length === 1 &&
(filters.id !== undefined || Boolean(normalizedName));
const contextsWithNodes = await Promise.all(
limitedContexts.map(async (context) => {
if (!canIncludeNodes) return context;
const nodes = await contextService.getNodesForContext(context.id);
return {
...context,
nodes: nodes.map((node) => ({
id: node.id,
title: node.title,
description: node.description ?? null,
context_id: node.context_id ?? null,
context: node.context ?? null,
updated_at: node.updated_at,
})),
};
})
);
const message = contextsWithNodes.length === 0
? 'No contexts found.'
: `Found ${contextsWithNodes.length} context${contextsWithNodes.length === 1 ? '' : 's'}:\n${contextsWithNodes.map((context) => `${context.name} (#${context.id}, ${context.count} nodes)`).join('\n')}`;
return {
success: true,
data: {
contexts: contextsWithNodes,
count: contextsWithNodes.length,
total_available: contexts.length,
filters_applied: filters,
},
message,
};
} catch (error) {
console.error('QueryContexts tool error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to query contexts',
data: {
contexts: [],
count: 0,
filters_applied: filters,
},
};
}
},
});
-1
View File
@@ -84,7 +84,6 @@ export const queryEdgeTool = tool({
id: connection.connected_node.id,
title: connection.connected_node.title,
description: truncateText(connection.connected_node.description, 140),
context: connection.connected_node.context ?? null,
formatted_display: formattedNode
}
};
+1 -6
View File
@@ -4,8 +4,6 @@ import { formatNodeForChat } from '../infrastructure/nodeFormatter';
import { directNodeLookup } from '@/services/retrieval/directNodeLookup';
type QueryNodeFilters = {
contextId?: number;
context_name?: string;
search?: string;
limit?: number;
createdAfter?: string;
@@ -15,10 +13,9 @@ type QueryNodeFilters = {
};
export const queryNodesTool = tool({
description: 'Find specific existing nodes in the graph by searching title, description, and source. Use this first when the user is trying to locate a node they already created or a specific existing podcast, article, idea, person, project, or note. For broader current-turn grounding of a substantive question, use retrieveQueryContext instead. Leave context blank by default. If the user explicitly wants a context filter, use context_name rather than a numeric ID.',
description: 'Find specific existing nodes in the graph by searching title, description, and source. Use this first when the user is trying to locate a node they already created or a specific existing podcast, article, idea, person, project, or note. For broader current-turn grounding of a substantive question, use retrieveQueryContext instead.',
inputSchema: z.object({
filters: z.object({
context_name: z.string().describe('Optional primary context name filter. Use only when the user explicitly wants a context-specific lookup.').optional(),
search: z.string().describe('Search term to match against node title, description, or source').optional(),
limit: z.number().min(1).max(50).default(10).describe('Maximum number of results to return'),
createdAfter: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created on or after this date.'),
@@ -33,8 +30,6 @@ export const queryNodesTool = tool({
const result = await directNodeLookup({
search: filters.search,
limit: filters.limit,
context_name: filters.context_name,
contextId: filters.contextId,
createdAfter: filters.createdAfter,
createdBefore: filters.createdBefore,
eventAfter: filters.eventAfter,
+1 -3
View File
@@ -7,15 +7,13 @@ export const retrieveQueryContextTool = tool({
inputSchema: z.object({
query: z.string().min(1).describe('The raw user query for this turn.'),
focused_node_id: z.number().int().positive().nullable().optional().describe('Optional currently focused node ID.'),
active_context_id: z.number().int().positive().nullable().optional().describe('Optional active context ID as a soft hint.'),
limit: z.number().int().min(1).max(12).optional().describe('Maximum number of nodes to return.'),
}),
execute: async ({ query, focused_node_id, active_context_id, limit }) => {
execute: async ({ query, focused_node_id, limit }) => {
try {
const result = await retrieveQueryContext({
query,
focused_node_id: focused_node_id ?? null,
active_context_id: active_context_id ?? null,
limit,
});
+4 -5
View File
@@ -1,9 +1,10 @@
import { tool } from 'ai';
import { z } from 'zod';
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
import { getInternalAuthHeaders } from '@/services/auth/internalAuth';
export const updateNodeTool = tool({
description: 'Update node fields when the existing node is clearly the same artifact and a net-new node would be redundant. Explicit user-directed updates should proceed once the target node is clear; if you are only proposing a change, ask first. Use this to enrich or correct nodes without losing canonical source content. Context is preserved unless the user explicitly wants it changed. When that happens, prefer context_name rather than a numeric ID. Use clear_context only when the user explicitly wants the context removed. When fixing a user-authored idea node, source should preserve the user\'s original wording as fully as possible. Never block an update because the description is incomplete. If the new description framing is materially inferred, complete the update and then invite one concise user feedback pass.',
description: 'Update node fields when the existing node is clearly the same artifact and a net-new node would be redundant. Explicit user-directed updates should proceed once the target node is clear; if you are only proposing a change, ask first. Use this to enrich or correct nodes without losing canonical source content. When fixing a user-authored idea node, source should preserve the user\'s original wording as fully as possible. Never block an update because the description is incomplete. If the new description framing is materially inferred, complete the update and then invite one concise user feedback pass.',
inputSchema: z.object({
id: z.number().describe('The ID of the node to update'),
updates: z.object({
@@ -12,8 +13,6 @@ export const updateNodeTool = tool({
source: z.string().optional().describe('Canonical source content for embedding. Use this to set or correct the raw source text. For user-authored ideas or dictated notes, preserve the user\'s original wording with only minimal cleanup rather than compressing it into a summary.'),
link: z.string().optional().describe('New link'),
event_date: z.string().optional().describe('When the thing actually happened (ISO 8601). Not when it was added to the graph.'),
context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants to assign this node to a known context.'),
clear_context: z.boolean().optional().describe('Set true only when the user explicitly wants to remove the node context.'),
metadata: z.record(z.any()).optional().describe('Metadata patch. It now merges with existing metadata instead of replacing the full blob. Use canonical keys: type, state, captured_method, captured_by, source_metadata.')
}).passthrough().describe('Object containing the fields to update. Derived analysis should be stored in a separate linked node, not appended to the source node.')
}),
@@ -30,12 +29,12 @@ export const updateNodeTool = tool({
// Call the nodes API endpoint
const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
headers: getInternalAuthHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(updates)
});
const result = await response.json();
if (!response.ok) {
return {
success: false,
-75
View File
@@ -1,75 +0,0 @@
import { tool } from 'ai';
import { z } from 'zod';
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
export const writeContextTool = tool({
description: 'Write one atomic durable context node to the graph only after the user has explicitly approved the save. Use this for agent-suggested capture after you already proposed the node briefly and got a clear yes. Prefer ordinary create/update flows for explicit user-directed capture. Never call it unless the user has clearly said yes.',
inputSchema: z.object({
title: z.string().min(1).max(160).describe('Clear proposed node title.'),
description: z.string().min(1).max(500).describe('Natural description of what this context is and why it matters.'),
source: z.string().optional().describe('Optional source or verbatim user wording to preserve.'),
context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants this saved under a known context.'),
metadata: z.record(z.any()).optional().describe('Optional metadata patch.'),
confirmed_by_user: z.boolean().describe('Must be true. Reject the write otherwise.'),
}).passthrough(),
execute: async ({ title, description, source, context_name, metadata, confirmed_by_user, ...legacyParams }) => {
if (!confirmed_by_user) {
return {
success: false,
error: 'writeContext requires explicit user confirmation before writing to the graph.',
data: null,
};
}
try {
const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: title.trim(),
description: description.trim(),
source: source?.trim() || undefined,
context_name: context_name?.trim() || undefined,
context_id: typeof legacyParams.context_id === 'number' || legacyParams.context_id === null
? legacyParams.context_id
: undefined,
metadata: {
captured_by: 'human',
captured_method: 'write_context',
...(metadata || {}),
},
}),
});
const result = await response.json();
if (!response.ok) {
return {
success: false,
error: result.error || 'Failed to write context node',
data: null,
};
}
const formattedDisplay = formatNodeForChat({
id: result.data.id,
title: result.data.title,
});
return {
success: true,
data: {
...result.data,
formatted_display: formattedDisplay,
},
message: `Saved context as ${formattedDisplay}`,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to write context node',
data: null,
};
}
},
});
+4 -2
View File
@@ -38,8 +38,9 @@ export const TOOL_GROUP_ASSIGNMENTS: Record<string, string> = {
retrieveQueryContext: 'core',
getNodesById: 'core',
queryEdge: 'core',
queryContexts: 'core',
searchContentEmbeddings: 'core',
listSkills: 'core',
readSkill: 'core',
// Orchestration: Web search and reasoning (orchestrator only)
webSearch: 'orchestration',
@@ -47,7 +48,6 @@ export const TOOL_GROUP_ASSIGNMENTS: Record<string, string> = {
// Execution: Write operations and extraction (workers only)
createNode: 'execution',
writeContext: 'execution',
updateNode: 'execution',
deleteNode: 'execution',
createEdge: 'execution',
@@ -56,6 +56,8 @@ export const TOOL_GROUP_ASSIGNMENTS: Record<string, string> = {
youtubeExtract: 'execution',
websiteExtract: 'execution',
paperExtract: 'execution',
writeSkill: 'execution',
deleteSkill: 'execution'
};
/**
+9 -5
View File
@@ -3,10 +3,8 @@ import { queryNodesTool } from '../database/queryNodes';
import { retrieveQueryContextTool } from '../database/retrieveQueryContext';
import { getNodesByIdTool } from '../database/getNodesById';
import { queryEdgeTool } from '../database/queryEdge';
import { queryContextsTool } from '../database/queryContexts';
import { createNodeTool } from '../database/createNode';
import { updateNodeTool } from '../database/updateNode';
import { writeContextTool } from '../database/writeContext';
import { deleteNodeTool } from '../database/deleteNode';
import { createEdgeTool } from '../database/createEdge';
import { updateEdgeTool } from '../database/updateEdge';
@@ -17,17 +15,22 @@ import { youtubeExtractTool } from '../other/youtubeExtract';
import { websiteExtractTool } from '../other/websiteExtract';
import { paperExtractTool } from '../other/paperExtract';
import { sqliteQueryTool } from '../other/sqliteQuery';
import { listSkillsTool } from '../skills/listSkills';
import { readSkillTool } from '../skills/readSkill';
import { writeSkillTool } from '../skills/writeSkill';
import { deleteSkillTool } from '../skills/deleteSkill';
import { logEvalToolCall } from '@/services/evals/evalsLogger';
// Read tools (graph queries)
// Read tools (graph queries + skills)
const CORE_TOOLS: Record<string, any> = {
sqliteQuery: sqliteQueryTool,
queryNodes: queryNodesTool,
retrieveQueryContext: retrieveQueryContextTool,
getNodesById: getNodesByIdTool,
queryEdge: queryEdgeTool,
queryContexts: queryContextsTool,
searchContentEmbeddings: searchContentEmbeddingsTool,
listSkills: listSkillsTool,
readSkill: readSkillTool,
};
// Utility tools
@@ -39,7 +42,6 @@ const ORCHESTRATION_TOOLS: Record<string, any> = {
// Write tools (includes extraction)
const EXECUTION_TOOLS: Record<string, any> = {
createNode: createNodeTool,
writeContext: writeContextTool,
updateNode: updateNodeTool,
deleteNode: deleteNodeTool,
createEdge: createEdgeTool,
@@ -47,6 +49,8 @@ const EXECUTION_TOOLS: Record<string, any> = {
youtubeExtract: youtubeExtractTool,
websiteExtract: websiteExtractTool,
paperExtract: paperExtractTool,
writeSkill: writeSkillTool,
deleteSkill: deleteSkillTool,
};
export const TOOL_SETS = {
+1 -1
View File
@@ -45,7 +45,7 @@ function isReadOnlyQuery(sql: string): boolean {
}
export const sqliteQueryTool = tool({
description: 'Execute read-only SQL queries (SELECT/WITH/PRAGMA). Tables: nodes, contexts, edges, chunks, logs, and migration snapshots. Use PRAGMA table_info(tablename) for schema.',
description: 'Execute read-only SQL queries (SELECT/WITH/PRAGMA). Tables: nodes, edges, chunks, logs, chats, voice_usage, and migration snapshots. Use PRAGMA table_info(tablename) for schema.',
inputSchema: z.object({
sql: z.string().describe('The SQL query to execute. Must be a SELECT, WITH, or PRAGMA statement.'),
+37
View File
@@ -0,0 +1,37 @@
import { tool } from 'ai';
import { z } from 'zod';
import { deleteSkill } from '@/services/skills/skillService';
import { eventBroadcaster } from '@/services/events';
export const deleteSkillTool = tool({
description: 'Delete a skill by name.',
inputSchema: z.object({
name: z.string().describe('The name of the skill to delete'),
}),
execute: async ({ name }) => {
try {
const result = deleteSkill(name);
if (!result.success) {
return {
success: false,
error: result.error,
data: null,
};
}
eventBroadcaster.broadcast({ type: 'GUIDE_UPDATED', data: { name } });
return {
success: true,
data: { name },
message: `Skill "${name}" deleted`,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to delete skill',
data: null,
};
}
},
});
+24
View File
@@ -0,0 +1,24 @@
import { tool } from 'ai';
import { z } from 'zod';
import { listSkills } from '@/services/skills/skillService';
export const listSkillsTool = tool({
description: 'List all available skills with their names and descriptions.',
inputSchema: z.object({}),
execute: async () => {
try {
const skills = listSkills();
return {
success: true,
data: skills,
message: `Found ${skills.length} skills`,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to list skills',
data: [],
};
}
},
});
+34
View File
@@ -0,0 +1,34 @@
import { tool } from 'ai';
import { z } from 'zod';
import { readSkill } from '@/services/skills/skillService';
export const readSkillTool = tool({
description: 'Read a skill by name. Returns the full markdown content with instructions.',
inputSchema: z.object({
name: z.string().describe('The name of the skill to read'),
}),
execute: async ({ name }) => {
try {
const skill = readSkill(name);
if (!skill) {
return {
success: false,
error: `Skill "${name}" not found`,
data: null,
};
}
return {
success: true,
data: skill,
message: `Loaded skill: ${skill.name}`,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to read skill',
data: null,
};
}
},
});
+38
View File
@@ -0,0 +1,38 @@
import { tool } from 'ai';
import { z } from 'zod';
import { writeSkill } from '@/services/skills/skillService';
import { eventBroadcaster } from '@/services/events';
export const writeSkillTool = tool({
description: 'Write or update a skill. Content should be full markdown with YAML frontmatter (name, description).',
inputSchema: z.object({
name: z.string().describe('The name of the skill to write'),
content: z.string().describe('Full markdown content including YAML frontmatter'),
}),
execute: async ({ name, content }) => {
try {
const result = writeSkill(name, content);
if (!result.success) {
return {
success: false,
error: result.error,
data: null,
};
}
eventBroadcaster.broadcast({ type: 'GUIDE_UPDATED', data: { name } });
return {
success: true,
data: { name },
message: `Skill "${name}" saved`,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to write skill',
data: null,
};
}
},
});