feat: port MCP retrieval and write hardening
- align os MCP/runtime/docs with ra-h contract - add safe direct node lookup and context-optional flows - gate edge and context writes behind confirmation
This commit is contained in:
@@ -432,32 +432,6 @@ export default function FocusPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const createEdgeAuto = async (targetNodeId: number) => {
|
||||
if (activeTab === null) return;
|
||||
try {
|
||||
const response = await fetch('/api/edges', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
from_node_id: activeTab,
|
||||
to_node_id: targetNodeId,
|
||||
source: 'user',
|
||||
explanation: '',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create edge');
|
||||
}
|
||||
|
||||
await fetchEdgesData(activeTab);
|
||||
} catch (error) {
|
||||
console.error('Error creating edge:', error);
|
||||
window.alert('Failed to create connection. Please try again.');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const createEdgeWithExplanation = async (targetNodeId: number, explanation: string) => {
|
||||
if (activeTab === null) return;
|
||||
try {
|
||||
@@ -1433,11 +1407,11 @@ export default function FocusPanel({
|
||||
onClose={() => setEdgeSearchOpen(false)}
|
||||
excludeNodeId={activeTab}
|
||||
onEdgeCreate={async (nodeId, explanation) => {
|
||||
if (explanation && explanation.trim()) {
|
||||
await createEdgeWithExplanation(nodeId, explanation.trim());
|
||||
} else {
|
||||
await createEdgeAuto(nodeId);
|
||||
if (!explanation || !explanation.trim()) {
|
||||
window.alert('Add a short explanation for the relationship before creating the edge.');
|
||||
return;
|
||||
}
|
||||
await createEdgeWithExplanation(nodeId, explanation.trim());
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -315,12 +315,12 @@ export default function NodeSearchModal({
|
||||
value={explanation}
|
||||
onChange={(e) => setExplanation(e.target.value.slice(0, 500))}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && explanation.trim()) {
|
||||
e.preventDefault();
|
||||
void handleCreate(selectedNode, explanation);
|
||||
}
|
||||
}}
|
||||
placeholder="Describe this connection... (optional, leave blank to auto-infer)"
|
||||
placeholder="Describe this connection in one clear sentence"
|
||||
rows={3}
|
||||
style={{
|
||||
width: '100%',
|
||||
@@ -351,7 +351,7 @@ export default function NodeSearchModal({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void handleCreate(selectedNode, explanation); }}
|
||||
disabled={submitting}
|
||||
disabled={submitting || !explanation.trim()}
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
@@ -368,7 +368,7 @@ export default function NodeSearchModal({
|
||||
opacity: submitting ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{submitting ? 'Creating…' : explanation.trim() ? 'Create connection' : 'Create connection (auto-infer)'}
|
||||
{submitting ? 'Creating…' : 'Create connection'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -13,8 +13,8 @@ description: "Use for graph read, write, connect, classify, or traverse operatio
|
||||
4. Search before create to avoid duplicates.
|
||||
5. Every create/update must include a natural description that clearly says what the thing is, why it matters here, and its current workflow status.
|
||||
6. Use event dates when known (when it happened, not when saved).
|
||||
7. Apply context only when it is an obvious match to one of the user's existing contexts and genuinely useful. One node gets at most one primary context, and leaving context blank is valid.
|
||||
8. Create edges when relationships are meaningful; edge explanations should read as a sentence.
|
||||
7. Leave context blank by default. Only apply context when the user explicitly wants it or when one obvious existing context is clearly useful. One node gets at most one primary context, and leaving context blank is valid.
|
||||
8. Do not create edges autonomously. Surface likely edge candidates first, then create them only after the user explicitly confirms.
|
||||
9. For user-authored ideas, notes, or dictated thoughts, preserve the user's wording in `source` as fully as possible with only minimal cleanup.
|
||||
|
||||
## Write Quality Contract
|
||||
@@ -24,7 +24,9 @@ description: "Use for graph read, write, connect, classify, or traverse operatio
|
||||
- `source`: full verbatim or canonical content of the node (transcript, article text, book passage, user's thoughts). This is what gets chunked and embedded for semantic search.
|
||||
- For idea capture from chat, the `source` should usually be the raw user thought, not a compressed assistant summary.
|
||||
- `link`: external source URL only.
|
||||
- `context_id`: the node's primary context. This field is optional. Omit it entirely unless it is an obvious existing match. Do not add `context_id: null` defensively.
|
||||
- Normal writes should omit context entirely unless the user explicitly wants one.
|
||||
- If context is intentionally provided, prefer `context_name`.
|
||||
- Treat numeric `context_id` as an internal implementation detail, not a normal agent-facing field.
|
||||
- `metadata`: use the canonical node metadata contract when metadata is needed:
|
||||
- `type`
|
||||
- `state` (`processed` or `not_processed`)
|
||||
@@ -34,6 +36,8 @@ description: "Use for graph read, write, connect, classify, or traverse operatio
|
||||
- `source_metadata`: factual source-specific details only. Keep it compact. No AI summaries or reasoning text.
|
||||
- metadata updates are merge-safe patches, not full-blob replacements. Do not assume `updateNode.metadata` wipes existing keys.
|
||||
- Derived analysis, briefs, and research notes should be stored in a separate linked node, not appended to the source node.
|
||||
- Explicit user-directed capture may write immediately after duplicate/update checks when the node is clear.
|
||||
- Agent-suggested capture should propose the node first and wait for confirmation.
|
||||
|
||||
## Description Standard
|
||||
|
||||
@@ -57,6 +61,8 @@ For user-authored idea capture, do not treat the inferred description as final i
|
||||
- why it belongs here
|
||||
- where it sits in their workflow
|
||||
|
||||
The first job of the description is object identity. It should start from what the thing is, not from interpretation.
|
||||
|
||||
Max 500 characters.
|
||||
|
||||
## Metadata Semantics
|
||||
@@ -71,17 +77,19 @@ Max 500 characters.
|
||||
1. Decide whether this is direct node retrieval or broader contextual grounding.
|
||||
2. If the user is trying to find a specific existing node, call `queryNodes` first.
|
||||
3. If the user is asking a broader question that would benefit from prior graph context, call `retrieveQueryContext`.
|
||||
4. Decide: answer only vs create vs update vs connect.
|
||||
4. Decide: answer only vs create vs update vs propose save vs propose edge.
|
||||
5. If something seems unusually durable and valuable, you may suggest a save in one short line like `Add "X" as a node?`
|
||||
6. Do not pester. If the user says no, ignores it, or moves on, do not keep asking.
|
||||
7. Only call `writeContext` or another write tool after explicit user confirmation.
|
||||
8. Execute minimum required writes.
|
||||
9. If the node is a user-authored idea and the contextual framing was inferred, offer one concise feedback pass after the write.
|
||||
10. Verify result reflects user intent exactly.
|
||||
7. For explicit user-directed capture, search before create when practical, prefer update over duplicate create, then write once the artifact is clear.
|
||||
8. For agent-suggested capture, propose the node first and wait for explicit confirmation before writing.
|
||||
9. When relationships are obvious, include brief proposed edges in the same reply, then wait for confirmation before calling `createEdge`.
|
||||
10. If the node is a user-authored idea and the contextual framing was inferred, offer one concise feedback pass after the write.
|
||||
11. Verify result reflects user intent exactly.
|
||||
|
||||
## Do Not
|
||||
|
||||
- Create duplicate nodes when an update is correct.
|
||||
- Write vague descriptions ("discusses", "explores", "is about").
|
||||
- Create weak or directionless edges.
|
||||
- Create edges before the user explicitly confirms the proposed relationship.
|
||||
- Ask to save every moderately useful point from the conversation.
|
||||
|
||||
@@ -114,7 +114,7 @@ Do your best to build the graph as useful context emerges.
|
||||
|
||||
- Add nodes when the user mentions concrete things worth keeping.
|
||||
- Assign a context only when it is an obvious match to one of the user's existing contexts. Prefer leaving context empty over low-confidence guessing.
|
||||
- Add edges when relationships are clear enough to explain well.
|
||||
- Surface likely edges when relationships are clear enough to explain well, but create them only after the user confirms.
|
||||
- Explain what you're adding in plain language so the user understands the structure as it develops.
|
||||
|
||||
When the graph is empty or nearly empty, bias toward creating a small, clean starter set rather than over-modeling everything.
|
||||
@@ -126,7 +126,7 @@ Before writing anything, call `readSkill('db-operations')` for full quality stan
|
||||
- Search before creating — avoid duplicates from day one
|
||||
- Every description must be concrete: what it IS and why it matters to them, not what it "explores" or "discusses"
|
||||
- Contexts are optional and should only be used for an obvious existing match; otherwise leave them empty
|
||||
- Every edge needs an explicit explanation sentence
|
||||
- Every edge needs an explicit explanation sentence, and agent-driven edge creation should only happen after confirmation
|
||||
|
||||
## Propose Before Writing
|
||||
|
||||
|
||||
+82
-170
@@ -1,24 +1,24 @@
|
||||
/**
|
||||
* Auto-Edge Creation Service
|
||||
* Potential edge suggestion helper.
|
||||
*
|
||||
* After Quick Capture creates a node, this service:
|
||||
* 1. Extracts candidate entity strings from the node's description
|
||||
* 2. Looks up existing entity nodes by exact title match
|
||||
* 3. Creates edges with explanations for matches
|
||||
*
|
||||
* This is a "fast path" for obvious connections only - conservative by design.
|
||||
* This module no longer writes edges automatically. It only identifies
|
||||
* obvious connection candidates so an agent or UI can propose them first.
|
||||
*/
|
||||
|
||||
import { nodeService, edgeService } from '@/services/database';
|
||||
import { nodeService } from '@/services/database';
|
||||
import { Node } from '@/types/database';
|
||||
|
||||
/**
|
||||
* Clean up a candidate entity string by removing common prefixes/suffixes.
|
||||
*/
|
||||
export interface PotentialEdgeSuggestion {
|
||||
from_node_id: number;
|
||||
to_node_id: number;
|
||||
to_node_title: string;
|
||||
explanation: string;
|
||||
candidate_text: string;
|
||||
}
|
||||
|
||||
function cleanEntityCandidate(candidate: string): string {
|
||||
let cleaned = candidate.trim();
|
||||
|
||||
// Remove common author/attribution prefixes
|
||||
const prefixPatterns = [
|
||||
/^by\s+/i,
|
||||
/^author:\s*/i,
|
||||
@@ -37,65 +37,8 @@ function cleanEntityCandidate(candidate: string): string {
|
||||
return cleaned.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract candidate entity strings from text using conservative heuristics.
|
||||
* Returns proper names, quoted titles, and recognized patterns.
|
||||
*/
|
||||
function extractCandidateEntities(text: string): string[] {
|
||||
if (!text || typeof text !== 'string') return [];
|
||||
|
||||
const candidates: Set<string> = new Set();
|
||||
|
||||
// Pattern 1: "By [Name]" pattern - common in article descriptions
|
||||
// Matches: "By Simon Willison", "by Sam Altman"
|
||||
const byPattern = /\b[Bb]y\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,2})\b/g;
|
||||
let match;
|
||||
while ((match = byPattern.exec(text)) !== null) {
|
||||
const name = match[1].trim();
|
||||
if (name.length >= 4 && !isGenericPhrase(name)) {
|
||||
candidates.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 2: Proper name sequences (2-4 capitalized words)
|
||||
// Matches: "Sam Altman", "Dario Amodei", "Peter Thiel"
|
||||
const properNamePattern = /\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,3})\b/g;
|
||||
while ((match = properNamePattern.exec(text)) !== null) {
|
||||
const name = match[1].trim();
|
||||
// Clean the candidate (remove "By ", etc.)
|
||||
const cleaned = cleanEntityCandidate(name);
|
||||
if (cleaned.length >= 4 && !isGenericPhrase(cleaned)) {
|
||||
candidates.add(cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 3: Quoted titles (single or double quotes)
|
||||
// Matches: "Zero to One", 'The Lean Startup'
|
||||
const quotedPattern = /["']([^"']{3,60})["']/g;
|
||||
while ((match = quotedPattern.exec(text)) !== null) {
|
||||
const title = match[1].trim();
|
||||
if (title.length >= 3 && !isGenericPhrase(title)) {
|
||||
candidates.add(title);
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 4: Known organization patterns
|
||||
// Matches: OpenAI, DeepMind, Y Combinator, Fly.io
|
||||
const orgPattern = /\b(OpenAI|DeepMind|Anthropic|Google|Microsoft|Meta|Apple|Amazon|Y Combinator|YC|Stripe|Coinbase|Fly\.io|Vercel|Cloudflare)\b/gi;
|
||||
while ((match = orgPattern.exec(text)) !== null) {
|
||||
candidates.add(match[1]);
|
||||
}
|
||||
|
||||
return Array.from(candidates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a phrase is too generic to be a useful entity reference.
|
||||
*/
|
||||
function isGenericPhrase(phrase: string): boolean {
|
||||
const normalized = phrase.toLowerCase();
|
||||
|
||||
// Common stopwords and generic terms
|
||||
const genericTerms = [
|
||||
'the author', 'the article', 'the book', 'the podcast',
|
||||
'this article', 'this book', 'this podcast', 'this paper',
|
||||
@@ -105,30 +48,58 @@ function isGenericPhrase(phrase: string): boolean {
|
||||
'united states', 'new york', 'san francisco', 'silicon valley'
|
||||
];
|
||||
|
||||
return genericTerms.some(term => normalized === term || normalized.startsWith(term + ' '));
|
||||
return genericTerms.some(term => normalized === term || normalized.startsWith(`${term} `));
|
||||
}
|
||||
|
||||
function extractCandidateEntities(text: string): string[] {
|
||||
if (!text || typeof text !== 'string') return [];
|
||||
|
||||
const candidates: Set<string> = new Set();
|
||||
|
||||
const byPattern = /\b[Bb]y\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,2})\b/g;
|
||||
let match;
|
||||
while ((match = byPattern.exec(text)) !== null) {
|
||||
const name = match[1].trim();
|
||||
if (name.length >= 4 && !isGenericPhrase(name)) {
|
||||
candidates.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
const properNamePattern = /\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,3})\b/g;
|
||||
while ((match = properNamePattern.exec(text)) !== null) {
|
||||
const cleaned = cleanEntityCandidate(match[1].trim());
|
||||
if (cleaned.length >= 4 && !isGenericPhrase(cleaned)) {
|
||||
candidates.add(cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
const quotedPattern = /["']([^"']{3,60})["']/g;
|
||||
while ((match = quotedPattern.exec(text)) !== null) {
|
||||
const title = match[1].trim();
|
||||
if (title.length >= 3 && !isGenericPhrase(title)) {
|
||||
candidates.add(title);
|
||||
}
|
||||
}
|
||||
|
||||
const orgPattern = /\b(OpenAI|DeepMind|Anthropic|Google|Microsoft|Meta|Apple|Amazon|Y Combinator|YC|Stripe|Coinbase|Fly\.io|Vercel|Cloudflare)\b/gi;
|
||||
while ((match = orgPattern.exec(text)) !== null) {
|
||||
candidates.add(match[1]);
|
||||
}
|
||||
|
||||
return Array.from(candidates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up existing nodes that match candidate entity strings.
|
||||
* Uses exact title matching (case-insensitive).
|
||||
*/
|
||||
async function findMatchingEntityNodes(candidates: string[]): Promise<Map<string, Node>> {
|
||||
const matches = new Map<string, Node>();
|
||||
|
||||
if (candidates.length === 0) return matches;
|
||||
|
||||
// Get all nodes (we'll filter in memory for exact title matches)
|
||||
// In a larger system, we'd use a more efficient query
|
||||
const allNodes = await nodeService.getNodes({ limit: 10000 });
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const normalizedCandidate = candidate.toLowerCase().trim();
|
||||
|
||||
// Find exact title match (case-insensitive)
|
||||
const matchingNode = allNodes.find(node => {
|
||||
const matchingNode = allNodes.find((node) => {
|
||||
const normalizedTitle = (node.title || '').toLowerCase().trim();
|
||||
if (normalizedTitle !== normalizedCandidate) return false;
|
||||
return node.title.length < 80;
|
||||
return normalizedTitle === normalizedCandidate && node.title.length < 80;
|
||||
});
|
||||
|
||||
if (matchingNode) {
|
||||
@@ -139,95 +110,36 @@ async function findMatchingEntityNodes(candidates: string[]): Promise<Map<string
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create edges from a new node to matched entity nodes.
|
||||
* Each edge includes an explanation for auditability.
|
||||
*/
|
||||
async function createAutoEdges(
|
||||
newNodeId: number,
|
||||
matches: Map<string, Node>
|
||||
): Promise<number> {
|
||||
let edgesCreated = 0;
|
||||
export async function suggestPotentialEdgesForNode(nodeId: number): Promise<PotentialEdgeSuggestion[]> {
|
||||
const node = await nodeService.getNodeById(nodeId);
|
||||
if (!node) {
|
||||
console.warn(`[autoEdge] Node ${nodeId} not found, skipping suggestion lookup`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const description = node.description || '';
|
||||
if (!description || description.length < 10) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidates = extractCandidateEntities(description);
|
||||
if (candidates.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const matches = await findMatchingEntityNodes(candidates);
|
||||
const suggestions: PotentialEdgeSuggestion[] = [];
|
||||
|
||||
for (const [candidateText, entityNode] of matches) {
|
||||
// Skip self-references
|
||||
if (entityNode.id === newNodeId) continue;
|
||||
|
||||
// Check if edge already exists
|
||||
const exists = await edgeService.edgeExists(newNodeId, entityNode.id);
|
||||
if (exists) continue;
|
||||
|
||||
try {
|
||||
await edgeService.createEdge({
|
||||
from_node_id: newNodeId,
|
||||
to_node_id: entityNode.id,
|
||||
explanation: `Explicitly mentioned in description: "${candidateText}"`,
|
||||
created_via: 'quick_capture_auto',
|
||||
source: 'ai_similarity',
|
||||
skip_inference: false, // Let Idea Genealogy classify the relationship
|
||||
});
|
||||
edgesCreated++;
|
||||
console.log(`[autoEdge] Created edge: ${newNodeId} → ${entityNode.id} (${entityNode.title})`);
|
||||
} catch (error) {
|
||||
console.warn(`[autoEdge] Failed to create edge to ${entityNode.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return edgesCreated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point: Run auto-edge creation for a newly created node.
|
||||
* This is designed to be called fire-and-forget (non-blocking).
|
||||
*/
|
||||
export async function runAutoEdgeCreation(nodeId: number): Promise<void> {
|
||||
try {
|
||||
// Fetch the newly created node
|
||||
const node = await nodeService.getNodeById(nodeId);
|
||||
if (!node) {
|
||||
console.warn(`[autoEdge] Node ${nodeId} not found, skipping auto-edge creation`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use description as the source of truth for entity extraction
|
||||
const description = node.description || '';
|
||||
if (!description || description.length < 10) {
|
||||
console.log(`[autoEdge] Node ${nodeId} has no/short description, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract candidate entities from description
|
||||
const candidates = extractCandidateEntities(description);
|
||||
if (candidates.length === 0) {
|
||||
console.log(`[autoEdge] No entity candidates found in node ${nodeId} description`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[autoEdge] Found ${candidates.length} candidates for node ${nodeId}:`, candidates);
|
||||
|
||||
// Find matching existing nodes
|
||||
const matches = await findMatchingEntityNodes(candidates);
|
||||
if (matches.size === 0) {
|
||||
console.log(`[autoEdge] No matching entity nodes found for node ${nodeId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create edges
|
||||
const edgesCreated = await createAutoEdges(nodeId, matches);
|
||||
console.log(`[autoEdge] Created ${edgesCreated} auto-edges for node ${nodeId}`);
|
||||
} catch (error) {
|
||||
console.error(`[autoEdge] Error in auto-edge creation for node ${nodeId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule auto-edge creation to run asynchronously (fire-and-forget).
|
||||
* Use this from the nodes API to avoid blocking the response.
|
||||
*/
|
||||
export function scheduleAutoEdgeCreation(nodeId: number): void {
|
||||
setImmediate(() => {
|
||||
runAutoEdgeCreation(nodeId).catch(error => {
|
||||
console.error(`[autoEdge] Scheduled auto-edge creation failed for node ${nodeId}:`, error);
|
||||
if (entityNode.id === nodeId) continue;
|
||||
suggestions.push({
|
||||
from_node_id: nodeId,
|
||||
to_node_id: entityNode.id,
|
||||
to_node_title: entityNode.title,
|
||||
explanation: `Explicitly mentioned in description: "${candidateText}"`,
|
||||
candidate_text: candidateText,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
@@ -234,6 +234,7 @@ async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: strin
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : `Failed to execute ${toolName}`;
|
||||
const capturedAt = new Date().toISOString();
|
||||
const title = deriveFallbackLinkTitle(url);
|
||||
const description =
|
||||
`Link record for this source. RA-H could not correctly process the URL during ingestion because ${message}. Stored so the source is not lost and can be revisited later.`;
|
||||
@@ -257,10 +258,15 @@ async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: strin
|
||||
captured_method: 'quick_add_link_fallback',
|
||||
captured_by: 'human',
|
||||
source_metadata: {
|
||||
capture_origin: 'quick_add',
|
||||
capture_path: 'quick_add_link_fallback',
|
||||
explicit_capture: true,
|
||||
source_url: url,
|
||||
attempted_pipeline: type,
|
||||
extraction_failed: true,
|
||||
extraction_error: message,
|
||||
refined_at: new Date().toISOString(),
|
||||
captured_at: capturedAt,
|
||||
refined_at: capturedAt,
|
||||
},
|
||||
},
|
||||
context_id: contextId,
|
||||
@@ -296,6 +302,7 @@ async function handleNoteQuickAdd(rawInput: string, task: string, userDescriptio
|
||||
throw new Error('Input is required to create a note');
|
||||
}
|
||||
|
||||
const capturedAt = new Date().toISOString();
|
||||
const title = deriveNoteTitle(content);
|
||||
const nodePayload: Record<string, unknown> = {
|
||||
title,
|
||||
@@ -307,7 +314,12 @@ async function handleNoteQuickAdd(rawInput: string, task: string, userDescriptio
|
||||
captured_method: 'quick_add_note',
|
||||
captured_by: 'human',
|
||||
source_metadata: {
|
||||
refined_at: new Date().toISOString(),
|
||||
capture_origin: 'quick_add',
|
||||
capture_path: 'quick_add_note',
|
||||
explicit_capture: true,
|
||||
input_type: 'note',
|
||||
captured_at: capturedAt,
|
||||
refined_at: capturedAt,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -351,6 +363,7 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string, cont
|
||||
throw new Error('Input is required to import a chat transcript');
|
||||
}
|
||||
|
||||
const capturedAt = new Date().toISOString();
|
||||
const summaryResult = await summarizeTranscript(transcript);
|
||||
const baseSummary = summaryResult.summary?.trim() || 'Captured chat transcript. Review the raw transcript for full detail.';
|
||||
|
||||
@@ -395,7 +408,12 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string, cont
|
||||
transcript_length_chars: transcript.length,
|
||||
transcript_length_words: wordCount,
|
||||
transcript_truncated_for_summary: summaryResult.truncated ?? false,
|
||||
summary_generated_at: new Date().toISOString(),
|
||||
capture_origin: 'quick_add',
|
||||
capture_path: 'quick_add_chat',
|
||||
explicit_capture: true,
|
||||
input_type: 'chat',
|
||||
captured_at: capturedAt,
|
||||
summary_generated_at: capturedAt,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { contextService } from '@/services/database/contextService';
|
||||
import { nodeService } from '@/services/database/nodes';
|
||||
import { countHighSignalQueryTermMatches, getHighSignalSearchTerms, scoreNodeSearchMatch } from '@/services/database/searchRanking';
|
||||
import type { Node } from '@/types/database';
|
||||
|
||||
export interface DirectNodeLookupInput {
|
||||
search?: string;
|
||||
limit?: number;
|
||||
context_name?: string;
|
||||
contextId?: number;
|
||||
createdAfter?: string;
|
||||
createdBefore?: string;
|
||||
eventAfter?: string;
|
||||
eventBefore?: string;
|
||||
}
|
||||
|
||||
export interface DirectNodeLookupResult {
|
||||
nodes: Node[];
|
||||
count: number;
|
||||
filtersApplied: {
|
||||
search?: string;
|
||||
limit: number;
|
||||
context_name?: string;
|
||||
createdAfter?: string;
|
||||
createdBefore?: string;
|
||||
eventAfter?: string;
|
||||
eventBefore?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeContextName(value: string | undefined): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const normalized = value.trim().replace(/\s+/g, ' ');
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
async function resolveSearchContext(input: DirectNodeLookupInput): Promise<{ contextId?: number; context_name?: string }> {
|
||||
const normalizedName = normalizeContextName(input.context_name);
|
||||
if (normalizedName) {
|
||||
const context = await contextService.getContextByName(normalizedName);
|
||||
if (!context) {
|
||||
console.warn(`directNodeLookup received unknown context_name "${normalizedName}"; ignoring context filter.`);
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
contextId: context.id,
|
||||
context_name: context.name,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof input.contextId === 'number') {
|
||||
const context = await contextService.getContextById(input.contextId);
|
||||
if (!context) {
|
||||
console.warn(`directNodeLookup received invalid legacy contextId ${input.contextId}; ignoring context filter.`);
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
contextId: context.id,
|
||||
context_name: context.name,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function hasStrongAnchorMatch(nodes: Node[], searchTerm: string): boolean {
|
||||
if (!searchTerm || nodes.length === 0) return false;
|
||||
const highSignalTerms = getHighSignalSearchTerms(searchTerm);
|
||||
const requiredMatches = Math.min(2, highSignalTerms.length || 1);
|
||||
return nodes.some(node => countHighSignalQueryTermMatches(node, searchTerm) >= requiredMatches);
|
||||
}
|
||||
|
||||
export async function directNodeLookup(input: DirectNodeLookupInput): Promise<DirectNodeLookupResult> {
|
||||
const limit = Math.min(Math.max(input.limit ?? 10, 1), 50);
|
||||
const searchTerm = input.search?.trim();
|
||||
|
||||
if (searchTerm && /^\d+$/.test(searchTerm)) {
|
||||
const nodeId = Number(searchTerm);
|
||||
const node = await nodeService.getNodeById(nodeId);
|
||||
return {
|
||||
nodes: node ? [node] : [],
|
||||
count: node ? 1 : 0,
|
||||
filtersApplied: {
|
||||
search: searchTerm,
|
||||
limit,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedContext = await resolveSearchContext(input);
|
||||
const effectiveFilters = {
|
||||
search: searchTerm,
|
||||
limit,
|
||||
contextId: resolvedContext.contextId,
|
||||
searchMode: 'standard' as const,
|
||||
createdAfter: input.createdAfter,
|
||||
createdBefore: input.createdBefore,
|
||||
eventAfter: input.eventAfter,
|
||||
eventBefore: input.eventBefore,
|
||||
};
|
||||
|
||||
let safeNodes = await nodeService.getNodes(effectiveFilters);
|
||||
|
||||
const hadExtraFilters = Boolean(
|
||||
effectiveFilters.contextId !== undefined ||
|
||||
effectiveFilters.createdAfter ||
|
||||
effectiveFilters.createdBefore ||
|
||||
effectiveFilters.eventAfter ||
|
||||
effectiveFilters.eventBefore
|
||||
);
|
||||
|
||||
if (searchTerm && hadExtraFilters && (safeNodes.length === 0 || !hasStrongAnchorMatch(safeNodes, searchTerm))) {
|
||||
console.warn(`directNodeLookup falling back to plain literal search for "${searchTerm}" after filtered lookup missed a strong anchor match.`);
|
||||
safeNodes = await nodeService.searchNodes(searchTerm, limit);
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
safeNodes = safeNodes
|
||||
.map(node => ({ node, score: scoreNodeSearchMatch(node, searchTerm) }))
|
||||
.sort((a, b) => b.score - a.score || b.node.updated_at.localeCompare(a.node.updated_at))
|
||||
.slice(0, limit)
|
||||
.map(entry => entry.node);
|
||||
} else {
|
||||
safeNodes = safeNodes.slice(0, limit);
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: safeNodes,
|
||||
count: safeNodes.length,
|
||||
filtersApplied: {
|
||||
search: searchTerm,
|
||||
limit,
|
||||
context_name: resolvedContext.context_name,
|
||||
createdAfter: input.createdAfter,
|
||||
createdBefore: input.createdBefore,
|
||||
eventAfter: input.eventAfter,
|
||||
eventBefore: input.eventBefore,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { validateEdgeExplanation } from '@/services/database/quality';
|
||||
|
||||
export const createEdgeTool = tool({
|
||||
description:
|
||||
'Create a relationship between two nodes. Provide an explanation and the system will infer the type and direction.\n\n' +
|
||||
'Create a relationship between two nodes only after the user has explicitly confirmed the proposed connection. Use this as the execution step after you surfaced candidate edges in plain language and got a clear yes. Provide an explanation and the system will infer the type and direction.\n\n' +
|
||||
'Examples of explanations:\n' +
|
||||
'- "Written by" (book → author)\n' +
|
||||
'- "Episode of this podcast" (episode → podcast)\n' +
|
||||
@@ -19,6 +19,9 @@ export const createEdgeTool = tool({
|
||||
explanation: z.string().describe(
|
||||
'REQUIRED: Why does this connection exist? The system will infer the relationship type from your explanation.'
|
||||
),
|
||||
confirmed_by_user: z.boolean().describe(
|
||||
'Must be true. Only create the edge after the user has explicitly approved this proposed relationship.'
|
||||
),
|
||||
source: z.enum(['user', 'ai', 'ai_similarity', 'helper_name']).default('ai').describe(
|
||||
'Source of this edge. Use "ai" for AI-created, "user" for manual, "ai_similarity" for similarity-based.'
|
||||
)
|
||||
@@ -27,6 +30,14 @@ export const createEdgeTool = tool({
|
||||
console.log('🔗 CreateEdge tool called with params:', JSON.stringify(params, null, 2));
|
||||
|
||||
try {
|
||||
if (!params.confirmed_by_user) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'createEdge requires explicit user confirmation before writing the relationship.',
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Validate basic IDs
|
||||
if (!Number.isFinite(params.from_node_id) || params.from_node_id <= 0) {
|
||||
return {
|
||||
|
||||
@@ -56,17 +56,16 @@ function inferSourceFromContext(params: { title: string; description?: string; s
|
||||
}
|
||||
|
||||
export const createNodeTool = tool({
|
||||
description: 'Create a node. Set context explicitly only when it is clear and useful; otherwise leave it blank. Focus on a clean title, a strong natural description, 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. 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.',
|
||||
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_id: z.number().int().positive().nullable().optional().describe('Optional primary context ID. Use when the node clearly belongs to a known context.'),
|
||||
context_name: z.string().optional().describe('Optional convenience context name. Resolved to a stable context_id before persistence.'),
|
||||
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) => {
|
||||
console.log('🎯 CreateNode tool called with params:', JSON.stringify(params, null, 2));
|
||||
try {
|
||||
@@ -76,7 +75,7 @@ export const createNodeTool = tool({
|
||||
const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...params, source: canonicalSource })
|
||||
body: JSON.stringify({ ...params, source: canonicalSource ?? params.source })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { contextService } from '@/services/database';
|
||||
import { nodeService } from '@/services/database/nodes';
|
||||
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
|
||||
import type { Node } from '@/types/database';
|
||||
import { countHighSignalQueryTermMatches, getHighSignalSearchTerms, scoreNodeSearchMatch } from '@/services/database/searchRanking';
|
||||
import { directNodeLookup } from '@/services/retrieval/directNodeLookup';
|
||||
|
||||
type QueryNodeFilters = {
|
||||
contextId?: number;
|
||||
context_name?: string;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
createdAfter?: string;
|
||||
@@ -17,128 +15,34 @@ 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 contextId unset unless you know an actual context-table ID; never pass a hub node ID or arbitrary node ID as contextId.',
|
||||
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.',
|
||||
inputSchema: z.object({
|
||||
filters: z.object({
|
||||
contextId: z.number().int().positive().describe('Optional primary context filter.').optional(),
|
||||
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.'),
|
||||
createdBefore: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created before this date.'),
|
||||
eventAfter: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes with event_date on or after this date.'),
|
||||
eventBefore: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes with event_date before this date.'),
|
||||
}).optional()
|
||||
}).passthrough().optional()
|
||||
}),
|
||||
execute: async ({ filters = {} }: { filters?: QueryNodeFilters }) => {
|
||||
console.log('🔍 QueryNodes tool called with filters:', JSON.stringify(filters, null, 2));
|
||||
try {
|
||||
const limit = filters.limit || 10;
|
||||
|
||||
const searchTerm = filters.search?.trim();
|
||||
if (searchTerm && /^\d+$/.test(searchTerm)) {
|
||||
const nodeId = Number(searchTerm);
|
||||
const node = await nodeService.getNodeById(nodeId);
|
||||
if (!node) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
nodes: [],
|
||||
count: 0,
|
||||
filters_applied: filters,
|
||||
},
|
||||
message: `Found 0 nodes matching id ${nodeId}`,
|
||||
};
|
||||
}
|
||||
|
||||
const formatted = formatNodeForChat({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
nodes: [{
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
created_at: node.created_at,
|
||||
updated_at: node.updated_at,
|
||||
event_date: node.event_date ?? null,
|
||||
formatted_display: formatted,
|
||||
}],
|
||||
count: 1,
|
||||
filters_applied: filters,
|
||||
},
|
||||
message: `Found 1 node matching id ${nodeId}:\n${formatted}`,
|
||||
};
|
||||
}
|
||||
|
||||
const runQuery = async (queryFilters: typeof filters): Promise<Node[]> => {
|
||||
const timeoutPromise: Promise<Node[] | undefined> = new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('QueryNodes timeout after 10 seconds')), 10000);
|
||||
});
|
||||
|
||||
const nodesPromise: Promise<Node[] | undefined> = nodeService.getNodes({
|
||||
limit,
|
||||
contextId: queryFilters.contextId,
|
||||
search: queryFilters.search,
|
||||
// Keep queryNodes literal-first. retrieveQueryContext is the broader semantic path.
|
||||
searchMode: 'standard',
|
||||
createdAfter: queryFilters.createdAfter,
|
||||
createdBefore: queryFilters.createdBefore,
|
||||
eventAfter: queryFilters.eventAfter,
|
||||
eventBefore: queryFilters.eventBefore,
|
||||
});
|
||||
|
||||
const nodes = await Promise.race<Node[] | undefined>([nodesPromise, timeoutPromise]);
|
||||
return Array.isArray(nodes) ? nodes : [];
|
||||
};
|
||||
|
||||
const effectiveFilters = { ...filters };
|
||||
if (effectiveFilters.contextId !== undefined) {
|
||||
const context = await contextService.getContextById(effectiveFilters.contextId);
|
||||
if (!context) {
|
||||
console.warn(`queryNodes received invalid contextId ${effectiveFilters.contextId}; ignoring context filter.`);
|
||||
delete effectiveFilters.contextId;
|
||||
}
|
||||
}
|
||||
|
||||
let safeNodes = await runQuery(effectiveFilters);
|
||||
|
||||
const hadExtraFilters = Boolean(
|
||||
effectiveFilters.contextId !== undefined ||
|
||||
effectiveFilters.createdAfter ||
|
||||
effectiveFilters.createdBefore ||
|
||||
effectiveFilters.eventAfter ||
|
||||
effectiveFilters.eventBefore
|
||||
);
|
||||
|
||||
const hasStrongAnchorMatch = (nodes: Node[]): boolean => {
|
||||
if (!searchTerm || nodes.length === 0) return false;
|
||||
const highSignalTerms = getHighSignalSearchTerms(searchTerm);
|
||||
const requiredMatches = Math.min(2, highSignalTerms.length || 1);
|
||||
return nodes.some(node => countHighSignalQueryTermMatches(node, searchTerm) >= requiredMatches);
|
||||
};
|
||||
|
||||
// Match the nav search behavior when the model overconstrains a direct lookup.
|
||||
// This prevents notes from disappearing behind synthetic date filters or weak filtered matches.
|
||||
if (searchTerm && hadExtraFilters && (safeNodes.length === 0 || !hasStrongAnchorMatch(safeNodes))) {
|
||||
console.warn(`queryNodes falling back to plain literal search for "${searchTerm}" after filtered lookup failed to return a strong anchor match.`);
|
||||
safeNodes = await nodeService.searchNodes(searchTerm, limit);
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
safeNodes = safeNodes
|
||||
.map(node => ({ node, score: scoreNodeSearchMatch(node, searchTerm) }))
|
||||
.sort((a, b) => b.score - a.score || b.node.updated_at.localeCompare(a.node.updated_at))
|
||||
.slice(0, limit)
|
||||
.map(entry => entry.node);
|
||||
}
|
||||
|
||||
const limitedNodes = safeNodes.slice(0, limit);
|
||||
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,
|
||||
eventBefore: filters.eventBefore,
|
||||
});
|
||||
|
||||
// Format nodes for chat display
|
||||
const formattedNodes = limitedNodes.map(node => {
|
||||
const formattedNodes = result.nodes.map(node => {
|
||||
const formatted = formatNodeForChat({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
@@ -155,14 +59,14 @@ export const queryNodesTool = tool({
|
||||
|
||||
// Create message with formatted node labels only (no full node payload)
|
||||
const formattedLabels = formattedNodes.map(node => node.formatted_display).join(', ');
|
||||
const message = `Found ${safeNodes.length} nodes${effectiveFilters.search ? ` matching: "${effectiveFilters.search}"` : ''}${formattedLabels ? `:\n${formattedLabels}` : ''}`;
|
||||
const message = `Found ${result.count} nodes${result.filtersApplied.search ? ` matching: "${result.filtersApplied.search}"` : ''}${formattedLabels ? `:\n${formattedLabels}` : ''}`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
nodes: formattedNodes,
|
||||
count: safeNodes.length,
|
||||
filters_applied: effectiveFilters
|
||||
count: result.count,
|
||||
filters_applied: result.filtersApplied
|
||||
},
|
||||
message: message
|
||||
};
|
||||
|
||||
@@ -4,8 +4,9 @@ import { edgeService } from '@/services/database/edges';
|
||||
import { validateEdgeExplanation } from '@/services/database/quality';
|
||||
|
||||
export const updateEdgeTool = tool({
|
||||
description: 'Update an edge explanation and/or source. Explanations must explicitly state the relationship.',
|
||||
description: 'Update an edge explanation and/or source only after the user explicitly confirmed the corrected relationship. Explanations must explicitly state the relationship.',
|
||||
inputSchema: z.object({
|
||||
confirmed_by_user: z.boolean().describe('Must be true. Reject the edge update otherwise.'),
|
||||
edge_id: z.number().describe('The ID of the edge to update'),
|
||||
updates: z.object({
|
||||
explanation: z.string().optional().describe('Updated relationship explanation'),
|
||||
@@ -17,6 +18,14 @@ export const updateEdgeTool = tool({
|
||||
console.log('📝 UpdateEdge tool called with params:', JSON.stringify(params, null, 2));
|
||||
|
||||
try {
|
||||
if (!params.confirmed_by_user) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Edge updates require explicit user confirmation before writing to the graph.',
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
// Validate that edge exists before updating
|
||||
const existingEdge = await edgeService.getEdgeById(params.edge_id);
|
||||
if (!existingEdge) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
||||
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
|
||||
|
||||
export const updateNodeTool = tool({
|
||||
description: 'Update node fields. Use this to enrich or correct nodes without losing canonical source content. Context is preserved unless context_id is supplied explicitly. 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. 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.',
|
||||
inputSchema: z.object({
|
||||
id: z.number().describe('The ID of the node to update'),
|
||||
updates: z.object({
|
||||
@@ -12,9 +12,10 @@ 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_id: z.number().int().positive().nullable().optional().describe('Primary context ID. Omit to preserve the existing context. Use null only to clear it intentionally.'),
|
||||
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.')
|
||||
}).describe('Object containing the fields to update. Derived analysis should be stored in a separate linked node, not appended to the source node.')
|
||||
}).passthrough().describe('Object containing the fields to update. Derived analysis should be stored in a separate linked node, not appended to the source node.')
|
||||
}),
|
||||
execute: async ({ id, updates }) => {
|
||||
try {
|
||||
|
||||
@@ -4,16 +4,16 @@ 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 sparingly for unusually valuable context. Never call it unless the user has clearly said yes.',
|
||||
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_id: z.number().int().positive().nullable().optional().describe('Optional primary context ID.'),
|
||||
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.'),
|
||||
}),
|
||||
execute: async ({ title, description, source, context_id, metadata, confirmed_by_user }) => {
|
||||
}).passthrough(),
|
||||
execute: async ({ title, description, source, context_name, metadata, confirmed_by_user, ...legacyParams }) => {
|
||||
if (!confirmed_by_user) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -30,7 +30,10 @@ export const writeContextTool = tool({
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
source: source?.trim() || undefined,
|
||||
context_id: context_id ?? null,
|
||||
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',
|
||||
|
||||
@@ -157,6 +157,7 @@ export const paperExtractTool = tool({
|
||||
const nodeTitle = title || result.metadata?.title || `PDF: ${new URL(url).pathname.split('/').pop()?.replace('.pdf', '')}`;
|
||||
const fallbackDescriptionLead = `PDF document titled "${nodeTitle}"`;
|
||||
const finalDescription = ensureNodeDescription(aiAnalysis?.nodeDescription, fallbackDescriptionLead);
|
||||
const capturedAt = new Date().toISOString();
|
||||
|
||||
const createResponse = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
|
||||
method: 'POST',
|
||||
@@ -172,13 +173,18 @@ export const paperExtractTool = tool({
|
||||
captured_method: 'paper_extract',
|
||||
captured_by: 'human',
|
||||
source_metadata: {
|
||||
capture_origin: 'extraction',
|
||||
capture_path: 'paper_extract',
|
||||
explicit_capture: true,
|
||||
source_url: url,
|
||||
hostname: new URL(url).hostname,
|
||||
author: result.metadata?.author || result.metadata?.info?.Author,
|
||||
pages: result.metadata?.pages,
|
||||
file_size: result.metadata?.file_size,
|
||||
content_length: result.source.length,
|
||||
extraction_method: result.metadata?.extraction_method || 'python_pdfplumber',
|
||||
refined_at: new Date().toISOString(),
|
||||
captured_at: capturedAt,
|
||||
refined_at: capturedAt,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -162,6 +162,7 @@ export const websiteExtractTool = tool({
|
||||
const nodeTitle = title || result.metadata?.title || `Website: ${new URL(url).hostname}`;
|
||||
const fallbackDescriptionLead = `${contentType === 'tweet' ? 'Tweet' : 'Website article'} from ${result.metadata?.author || result.metadata?.site_name || new URL(url).hostname} titled "${nodeTitle}"`;
|
||||
const finalDescription = ensureNodeDescription(aiAnalysis?.nodeDescription, fallbackDescriptionLead);
|
||||
const capturedAt = new Date().toISOString();
|
||||
|
||||
const createResponse = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
|
||||
method: 'POST',
|
||||
@@ -178,12 +179,17 @@ export const websiteExtractTool = tool({
|
||||
captured_method: 'website_extract',
|
||||
captured_by: 'human',
|
||||
source_metadata: {
|
||||
capture_origin: 'extraction',
|
||||
capture_path: 'website_extract',
|
||||
explicit_capture: true,
|
||||
source_url: url,
|
||||
hostname: new URL(url).hostname,
|
||||
author: result.metadata?.author,
|
||||
published_date: result.metadata?.published_date || result.metadata?.date,
|
||||
content_length: result.source.length,
|
||||
extraction_method: result.metadata?.extraction_method || 'python_beautifulsoup',
|
||||
refined_at: new Date().toISOString(),
|
||||
captured_at: capturedAt,
|
||||
refined_at: capturedAt,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -193,6 +193,7 @@ export const youtubeExtractTool = tool({
|
||||
const transcriptSummary = await summariseTranscript(nodeTitle, result.source);
|
||||
const fallbackDescriptionLead = `YouTube video from ${result.metadata?.channel_name || 'an unknown channel'} titled "${nodeTitle}"`;
|
||||
const finalDescription = ensureNodeDescription(aiAnalysis?.nodeDescription, fallbackDescriptionLead);
|
||||
const capturedAt = new Date().toISOString();
|
||||
|
||||
const createResponse = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
|
||||
method: 'POST',
|
||||
@@ -208,6 +209,10 @@ export const youtubeExtractTool = tool({
|
||||
captured_method: 'youtube_extract',
|
||||
captured_by: 'human',
|
||||
source_metadata: {
|
||||
capture_origin: 'extraction',
|
||||
capture_path: 'youtube_extract',
|
||||
explicit_capture: true,
|
||||
source_url: url,
|
||||
video_id: result.metadata?.video_id,
|
||||
channel_name: result.metadata?.channel_name,
|
||||
channel_url: result.metadata?.channel_url,
|
||||
@@ -218,7 +223,8 @@ export const youtubeExtractTool = tool({
|
||||
extraction_method: result.metadata?.extraction_method,
|
||||
summary_origin: transcriptSummary ? 'transcript_summary' : 'metadata_description',
|
||||
transcript_summary: transcriptSummary,
|
||||
refined_at: new Date().toISOString(),
|
||||
captured_at: capturedAt,
|
||||
refined_at: capturedAt,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user