json-object-editor
Version:
JOE the Json Object Editor | Platform Edition
705 lines (628 loc) • 33.2 kB
JavaScript
const OpenAI = require("openai");
const MCP = require("../modules/MCP.js");
function IntentOperator() {
var self = this;
function coloredLog(message) {
console.log(JOE.Utils.color('[intent-operator]', 'plugin', false), message);
}
function getAPIKey() {
const setting = JOE.Utils.Settings('OPENAI_API_KEY');
if (!setting) throw new Error("Missing OPENAI_API_KEY setting");
return setting;
}
function newClient() {
return new OpenAI({ apiKey: getAPIKey() });
}
/**
* Default intents (hard-coded)
*/
const DEFAULT_INTENTS = [
{
name: 'create_object',
description: 'User wants to create a new object',
handoff: 'handoff_worker',
examples: ['add a task', 'create a project', 'new task called clean my room'],
operators: ['add', 'create', 'new', 'make'],
required_information: ['primary_itemtype','name of item']
},
{
name: 'update_object',
description: 'Modify existing object',
handoff: 'handoff_worker',
examples: ['update the status', 'change the name', 'edit task abc123'],
operators: ['update', 'edit', 'change', 'modify'],
required_information: ['primary_itemtype', 'object_id']
},
{
name: 'search',
description: 'Retrieve or list objects',
handoff: 'handoff_search',
examples: ['find all projects', 'show me tasks', 'list users'],
operators: ['find', 'search', 'show', 'list', 'get'],
required_information: []
},
{
name: 'chat',
description: 'Conversational/help/explanation',
handoff: 'handoff_chat',
examples: ['what can you help me with?', 'explain how this works', 'help me'],
operators: ['ask', 'help', 'explain', 'what', 'how'],
required_information: []
},
{
name: 'autofill',
description: 'Generate field values',
handoff: 'handoff_autofill',
examples: ['generate a name', 'fill in the description', 'suggest tags'],
operators: ['generate', 'fill', 'suggest', 'autofill'],
required_information: ['primary_itemtype']
},
{
name: 'analyze',
description: 'Analytical reasoning over objects',
handoff: 'handoff_worker',
examples: ['analyze the data', 'summarize projects', 'count tasks'],
operators: ['analyze', 'summarize', 'count', 'aggregate'],
required_information: ['primary_itemtype']
}
];
/**
* Load and merge intents from setting with defaults
*/
function loadIntents() {
const intents = DEFAULT_INTENTS.slice(); // Copy defaults
const intentMap = {};
// Create map of default intents by name
intents.forEach(function(intent) {
intentMap[intent.name] = intent;
});
// Load custom intents from setting
try {
const customIntents = JOE.Utils.Settings('ai_intents');
if (Array.isArray(customIntents) && customIntents.length > 0) {
customIntents.forEach(function(custom) {
if (!custom || !custom.name) return; // Skip invalid entries
if (intentMap[custom.name]) {
// Overwrite existing intent
const index = intents.findIndex(function(i) { return i.name === custom.name; });
if (index !== -1) {
intents[index] = Object.assign({}, intents[index], custom, { overwrite: true });
}
} else {
// Add new intent
intents.push(Object.assign({}, custom, { overwrite: false }));
}
});
}
} catch (e) {
coloredLog('Warning: Failed to load ai_intents setting: ' + e.message);
}
return intents;
}
/**
* Create an empty intent object structure
*/
function createEmptyIntent() {
return {
intent: null,
operator: null,
operator_span: null,
primary_itemtype: null,
secondary_itemtypes: [],
needs_clarification: true,
questions: [],
confidence: 0,
next_step: null
};
}
/**
* Calculate elapsed time in seconds with 3 decimal places
*/
function calculateElapsed(startTime) {
return parseFloat(((Date.now() - startTime) / 1000).toFixed(3));
}
/**
* Create a standardized error response
*/
function createErrorResponse(startTime, errors, additionalFields) {
return {
intent: createEmptyIntent(),
errors: Array.isArray(errors) ? errors : [errors],
failedat: 'intent-operator',
elapsed: calculateElapsed(startTime),
...additionalFields
};
}
/**
* Build context pack from all loaded schemas and MCP tools
*/
function buildContextPack() {
const summaries = JOE.Schemas.summary || {};
const allowed_itemtypes = Object.keys(summaries).sort();
const itemtype_fields = {};
// Build itemtype_relationships from schema summaries
const itemtype_relationships = {};
allowed_itemtypes.forEach(function(itemtype) {
const summary = summaries[itemtype];
if (!summary || !Array.isArray(summary.fields)) {
itemtype_fields[itemtype] = [];
itemtype_relationships[itemtype] = [];
return;
}
// Extract field names, excluding system-managed fields
const fieldNames = summary.fields
.map(function(f) { return f && f.name; })
.filter(function(name) {
return name &&
name !== '_id' &&
name !== 'itemtype' &&
name !== 'joeUpdated' &&
name !== 'created';
});
itemtype_fields[itemtype] = fieldNames;
// Extract relationships from summary.relationships.outbound
if (summary.relationships && Array.isArray(summary.relationships.outbound)) {
const targetSchemas = summary.relationships.outbound.map(function(rel) {
return rel && rel.targetSchema;
}).filter(function(schema) {
return schema && allowed_itemtypes.indexOf(schema) !== -1;
});
itemtype_relationships[itemtype] = targetSchemas;
} else {
itemtype_relationships[itemtype] = [];
}
});
// Get MCP tools from minimal toolset (slimmed, safe subset)
const mcpToolNames = MCP && MCP.getToolNamesForToolset ? MCP.getToolNamesForToolset('minimal', null) : [];
const mcpTools = [];
if (mcpToolNames.length > 0 && MCP && MCP.descriptions) {
mcpToolNames.forEach(function(toolName) {
const description = MCP.descriptions[toolName] || '';
mcpTools.push({
name: toolName,
description: description
});
});
}
return {
allowed_itemtypes: allowed_itemtypes,
itemtype_fields: itemtype_fields,
itemtype_relationships: itemtype_relationships,
mcp_tools: mcpTools
};
}
/**
* Infer schemas_to_load from itemtypes and relationships
*/
function inferSchemasToLoad(primaryItemtype, secondaryItemtypes, contextPack) {
const schemas = new Set();
// Add primary and secondary itemtypes
if (primaryItemtype) {
schemas.add(primaryItemtype);
}
if (Array.isArray(secondaryItemtypes)) {
secondaryItemtypes.forEach(function(it) {
if (it) schemas.add(it);
});
}
// Add all target schemas from relationships
if (primaryItemtype && contextPack.itemtype_relationships) {
const relationships = contextPack.itemtype_relationships[primaryItemtype] || [];
relationships.forEach(function(targetSchema) {
if (targetSchema) schemas.add(targetSchema);
});
}
// Add relationships from secondary itemtypes too
if (Array.isArray(secondaryItemtypes) && contextPack.itemtype_relationships) {
secondaryItemtypes.forEach(function(secondary) {
if (secondary) {
const relationships = contextPack.itemtype_relationships[secondary] || [];
relationships.forEach(function(targetSchema) {
if (targetSchema) schemas.add(targetSchema);
});
}
});
}
return Array.from(schemas).sort();
}
/**
* Extract JSON from response text (handles markdown code blocks)
*/
function extractJsonText(text) {
if (!text) return null;
// Try to find JSON in markdown code blocks
const jsonMatch = text.match(/```(?:json)?\s*(\{[\s\S]*\})\s*```/);
if (jsonMatch) return jsonMatch[1];
// Try to find JSON object directly
const objMatch = text.match(/\{[\s\S]*\}/);
if (objMatch) return objMatch[0];
return null;
}
/**
* Validate and normalize intent response
*/
function validateIntent(rawResponse, contextPack, intents) {
const errors = [];
const warnings = [];
const allowedIntents = intents.map(function(i) { return i.name; });
const allowedItemtypes = contextPack.allowed_itemtypes || [];
// Ensure required fields exist
const result = {
intent: rawResponse.intent || null,
operator: rawResponse.operator || null,
operator_span: rawResponse.operator_span || null,
primary_itemtype: rawResponse.primary_itemtype || null,
secondary_itemtypes: Array.isArray(rawResponse.secondary_itemtypes) ? rawResponse.secondary_itemtypes : [],
needs_clarification: rawResponse.needs_clarification === true,
questions: Array.isArray(rawResponse.questions) ? rawResponse.questions : [],
confidence: typeof rawResponse.confidence === 'number' ? Math.max(0, Math.min(1, rawResponse.confidence)) : 0,
next_step: rawResponse.next_step || null
};
// Extract extracted_raw for validation (will be moved to next_step.data)
const extracted_raw = (rawResponse.extracted_raw && typeof rawResponse.extracted_raw === 'object') ? rawResponse.extracted_raw : {};
// Validate intent
if (!result.intent) {
errors.push('Missing required field: intent');
} else if (allowedIntents.indexOf(result.intent) === -1) {
errors.push('Invalid intent: ' + result.intent + ' (must be one of: ' + allowedIntents.join(', ') + ')');
result.intent = null;
}
// Validate primary_itemtype
if (result.primary_itemtype && allowedItemtypes.indexOf(result.primary_itemtype) === -1) {
warnings.push('primary_itemtype "' + result.primary_itemtype + '" not in allowed list; setting to null');
result.primary_itemtype = null;
}
// Filter secondary_itemtypes
const validSecondary = result.secondary_itemtypes.filter(function(it) {
return allowedItemtypes.indexOf(it) !== -1;
});
if (validSecondary.length !== result.secondary_itemtypes.length) {
warnings.push('Some secondary_itemtypes were filtered out (not in allowed list)');
}
result.secondary_itemtypes = validSecondary;
// Check semantic consistency between primary_itemtype and secondary_itemtypes
if (result.primary_itemtype && result.secondary_itemtypes.length > 0 && contextPack.itemtype_relationships) {
const primaryRelationships = contextPack.itemtype_relationships[result.primary_itemtype] || [];
const mismatches = result.secondary_itemtypes.filter(function(secondary) {
// Check if primary can reference this secondary itemtype
return primaryRelationships.indexOf(secondary) === -1;
});
if (mismatches.length > 0) {
warnings.push('Semantic mismatch: ' + result.primary_itemtype + ' does not reference ' + mismatches.join(', ') +
' (valid references: ' + (primaryRelationships.length > 0 ? primaryRelationships.join(', ') : 'none') + ')');
// Force clarification when semantic mismatch detected
if (!result.needs_clarification) {
result.needs_clarification = true;
}
if (result.questions.length === 0) {
result.questions.push('Did you mean a different itemtype? ' + result.primary_itemtype +
' typically relates to ' + (primaryRelationships.length > 0 ? primaryRelationships.join(', ') : 'nothing') +
', but you mentioned ' + mismatches.join(', ') + '.');
}
}
}
// Filter extracted fields by field whitelist
let validatedExtracted = {};
if (result.primary_itemtype && contextPack.itemtype_fields[result.primary_itemtype]) {
const allowedFields = contextPack.itemtype_fields[result.primary_itemtype];
const rawKeys = Object.keys(extracted_raw);
rawKeys.forEach(function(key) {
if (allowedFields.indexOf(key) === -1) {
warnings.push('Field "' + key + '" in extracted_raw not in whitelist for ' + result.primary_itemtype);
} else {
validatedExtracted[key] = extracted_raw[key];
}
});
} else {
validatedExtracted = extracted_raw;
}
// Set next_step based on intent if missing
if (result.intent && !result.next_step) {
const intentDef = intents.find(function(i) { return i.name === result.intent; });
if (intentDef && intentDef.handoff) {
// Convert handoff string to structured object
result.next_step = {
handler: intentDef.handoff,
prompt: null,
data: null
};
}
}
// Validate next_step structure if present
if (result.next_step) {
if (typeof result.next_step === 'string') {
// Legacy string format - convert to structured
result.next_step = {
handler: result.next_step,
prompt: null,
data: null
};
} else if (typeof result.next_step === 'object' && result.next_step !== null) {
// Ensure structured format has required fields
if (!result.next_step.handler) {
result.next_step.handler = null;
}
if (result.next_step.prompt === undefined) {
result.next_step.prompt = null;
}
if (result.next_step.data === undefined) {
result.next_step.data = null;
}
} else {
result.next_step = null;
}
}
// Move extracted_raw to next_step.data (if next_step exists)
if (result.next_step && Object.keys(validatedExtracted).length > 0) {
// Merge with existing data if present, otherwise set to extracted fields
if (result.next_step.data && typeof result.next_step.data === 'object') {
result.next_step.data = Object.assign({}, result.next_step.data, validatedExtracted);
} else {
result.next_step.data = validatedExtracted;
}
}
return {
intent: result,
errors: errors,
warnings: warnings
};
}
/**
* Validate objects_to_resolve array
*/
function validateObjectsToResolve(objectsToResolve, contextPack, primaryItemtype) {
const errors = [];
const warnings = [];
const validated = [];
if (!Array.isArray(objectsToResolve)) {
return { objects: [], errors: ['objects_to_resolve must be an array'], warnings: [] };
}
objectsToResolve.forEach(function(obj, index) {
if (!obj || typeof obj !== 'object') {
warnings.push('objects_to_resolve[' + index + '] is not a valid object');
return;
}
const itemtype = obj.itemtype;
const name = obj.name;
const field = obj.field;
if (!itemtype || typeof itemtype !== 'string') {
errors.push('objects_to_resolve[' + index + '] missing or invalid itemtype');
return;
}
if (!name || typeof name !== 'string') {
errors.push('objects_to_resolve[' + index + '] missing or invalid name');
return;
}
if (!field || typeof field !== 'string') {
errors.push('objects_to_resolve[' + index + '] missing or invalid field');
return;
}
// Validate itemtype exists
if (contextPack.allowed_itemtypes.indexOf(itemtype) === -1) {
warnings.push('objects_to_resolve[' + index + '] itemtype "' + itemtype + '" not in allowed list');
}
// Validate field exists and is a reference field for primary_itemtype
if (primaryItemtype && contextPack.itemtype_fields[primaryItemtype]) {
const allowedFields = contextPack.itemtype_fields[primaryItemtype];
if (allowedFields.indexOf(field) === -1) {
warnings.push('objects_to_resolve[' + index + '] field "' + field + '" not in allowed fields for ' + primaryItemtype);
}
}
// Build validated object
const validatedObj = {
itemtype: itemtype,
name: name,
field: field,
operation: obj.operation || 'link',
purpose: obj.purpose || 'Resolve ' + itemtype + ' name "' + name + '" to _id for ' + field + ' field'
};
validated.push(validatedObj);
});
return {
objects: validated,
errors: errors,
warnings: warnings
};
}
/**
* Main handler: classify intent from natural language
*/
this.default = async function(data, req, res) {
const startTime = Date.now();
try {
const text = (data && data.text) || '';
if (!text || typeof text !== 'string' || !text.trim()) {
return createErrorResponse(startTime, ['Missing or empty "text" parameter']);
}
// Build context pack
const contextPack = buildContextPack();
// Check if context pack is empty
if (!contextPack.allowed_itemtypes || contextPack.allowed_itemtypes.length === 0) {
return createErrorResponse(startTime, ['No schemas loaded - context pack is empty']);
}
coloredLog('Context pack: ' + contextPack.allowed_itemtypes.length + ' itemtypes');
// Log prompt size for debugging
const contextPackSize = JSON.stringify(contextPack.itemtype_fields).length;
coloredLog('Context pack size: ' + contextPackSize + ' characters');
// Load intents (used for both prompt and validation)
const intents = loadIntents();
const allowedIntents = intents.map(function(i) { return i.name; });
// Build intent descriptions for prompt (including required_information)
const intentDescriptions = intents.map(function(i) {
let desc = i.name + ': ' + i.description;
if (i.required_information && Array.isArray(i.required_information) && i.required_information.length > 0) {
desc += ' (requires: ' + i.required_information.join(', ') + ')';
}
return desc;
}).join('\n');
// Build MCP tools description for prompt
let mcpToolsText = '';
if (contextPack.mcp_tools && contextPack.mcp_tools.length > 0) {
mcpToolsText = '\nAvailable MCP tools:\n' +
contextPack.mcp_tools.map(function(tool) {
return '- ' + tool.name + ': ' + tool.description;
}).join('\n') + '\n';
}
// Build relationships description for prompt
let relationshipsText = '';
if (contextPack.itemtype_relationships) {
relationshipsText = '\nItemtype relationships (which itemtypes can reference which):\n';
Object.keys(contextPack.itemtype_relationships).forEach(function(itemtype) {
const refs = contextPack.itemtype_relationships[itemtype];
if (refs && refs.length > 0) {
relationshipsText += '- ' + itemtype + ' can reference: ' + refs.join(', ') + '\n';
}
});
relationshipsText += '\n';
}
// Build prompt
const prompt = [
'You are JOE (Json Object Editor) Intent Classifier.',
'',
'Task: Analyze the user input and return a structured intent JSON object.',
'',
'Allowed intents:',
intentDescriptions,
'',
'Intent names: ' + allowedIntents.join(', '),
'',
'Available itemtypes: ' + contextPack.allowed_itemtypes.join(', '),
'',
'For each itemtype, allowed fields:',
JSON.stringify(contextPack.itemtype_fields, null, 2),
relationshipsText,
mcpToolsText,
'Output format (JSON only, no markdown):',
'{',
' "intent": "one of the allowed intents",',
' "operator": "normalized verb label (e.g., add, create, edit, find, show)",',
' "operator_span": "literal phrase from input (optional)",',
' "primary_itemtype": "main object type being acted on (or null)",',
' "secondary_itemtypes": ["related object types"],',
' "needs_clarification": false,',
' "questions": [],',
' "confidence": 0.0,',
' "next_step": { "handler": "handler_name", "prompt": "optional prompt text", "data": { "field_name": "extracted value" } },',
' "execution_plan": ["step 1", "step 2", "step 3"],',
' "objects_to_resolve": [',
' { "itemtype": "project", "name": "home", "field": "project", "operation": "link", "purpose": "Resolve project name to _id" }',
' ],',
' "schemas_to_load": ["task", "project", "status"]',
'}',
'',
'Rules:',
'- Choose intent from allowed list only.',
'- Choose itemtypes from available list only.',
'- CRITICAL: Check semantic consistency using the itemtype_relationships data. If the input mentions a secondary itemtype (e.g., "project"), ensure your primary_itemtype can reference it. If primary_itemtype cannot reference the mentioned secondary_itemtype, set needs_clarification=true and add a clarifying question.',
'- CRITICAL: If you detect a typo or uncertain word that might be an itemtype (e.g., "tack" might be "task", "projct" might be "project"), set needs_clarification=true and add a question like "Did you mean \'task\' instead of \'tack\'?"',
'- CRITICAL: If there is a semantic mismatch (e.g., primary_itemtype="page" but context mentions "project", when page references "site" not "project"), set needs_clarification=true and add a clarifying question.',
'- Only extract fields that exist in the field whitelist for the primary_itemtype.',
'- Put extracted field values in next_step.data (as an object with field names as keys).',
'- CRITICAL: Identify objects that need resolution: If a field value in next_step.data is a name (not an _id/CUID format), check the itemtype_relationships for the primary_itemtype. If the field name matches a relationship target (e.g., if task can reference "project" and you have field "project": "home"), then "home" is a name that needs fuzzy search to get _id. Add to objects_to_resolve with: itemtype (the targetSchema from relationships), name (the value), field (field name), operation (link/update/reference), and purpose.',
'- Example: If next_step.data has { "project": "home" } and primary_itemtype is "task", check itemtype_relationships["task"]. If it includes "project", then add { "itemtype": "project", "name": "home", "field": "project", "operation": "link", "purpose": "Resolve project name \'home\' to _id for linking task" } to objects_to_resolve.',
'- Only add to objects_to_resolve if the value looks like a name (not a CUID - CUIDs are long alphanumeric strings like "clx123abc456def789").',
'- CRITICAL: For schemas_to_load, include primary_itemtype, secondary_itemtypes, and all targetSchema values from itemtype_relationships. You can also add additional schemas if needed for the operation (e.g., if you need to validate field types).',
'- Set needs_clarification=true if ambiguous or missing required info (check required_information for each intent).',
'- Set confidence conservatively - if itemtype is uncertain, there are typos, or semantic mismatches, use lower confidence (0.3-0.6) and set needs_clarification=true.',
'- next_step should be a structured object with handler (the handoff handler name), prompt (optional descriptive text), and data (object with extracted field values).',
'- Always generate execution_plan as an array of strings describing the steps needed.',
'- In execution_plan, reference MCP tools consistently using format: "Call MCP tool: toolName" (e.g., "Call MCP tool: getObject to fetch task abc123", "Call MCP tool: saveObject to persist the task").',
'- Use real schema names and field names from the context pack.',
'- Keep steps at medium detail level (e.g., "Create a task object", "Set name field to clean my room", "Call MCP tool: saveObject to persist").',
'- Even if clarification is needed, describe what you think the plan should be.',
'',
'User input: ' + text
].join('\n');
// Call OpenAI
// Using gpt-4o-mini as it's proven fast and available (gpt-5-nano may not exist yet)
const openai = newClient();
const apiStartTime = Date.now();
const response = await openai.responses.create({
//model: 'gpt-4o-mini',
model: 'gpt-4.1-nano',
instructions: 'You are a JSON-only output assistant. Return only valid JSON, no markdown, no explanations.',
input: prompt
});
const apiElapsed = ((Date.now() - apiStartTime) / 1000).toFixed(3);
coloredLog('OpenAI API call took: ' + apiElapsed + ' seconds');
const rawText = response.output_text || '';
const jsonText = extractJsonText(rawText);
if (!jsonText) {
return createErrorResponse(startTime, ['Failed to extract JSON from model response'], {
raw_response: rawText.substring(0, 500)
});
}
let rawResponse;
try {
rawResponse = JSON.parse(jsonText);
} catch (e) {
return createErrorResponse(startTime, ['Failed to parse JSON: ' + e.message], {
raw_response: rawText.substring(0, 500)
});
}
// Validate and normalize (using intents loaded above)
const validated = validateIntent(rawResponse, contextPack, intents);
// Extract execution_plan from raw response (not from intent object)
const execution_plan = Array.isArray(rawResponse.execution_plan) ? rawResponse.execution_plan : [];
// Validate objects_to_resolve
const objectsToResolveRaw = Array.isArray(rawResponse.objects_to_resolve) ? rawResponse.objects_to_resolve : [];
const objectsToResolveValidated = validateObjectsToResolve(objectsToResolveRaw, contextPack, validated.intent.primary_itemtype);
// Infer schemas_to_load and merge with model-provided ones
const inferredSchemas = inferSchemasToLoad(
validated.intent.primary_itemtype,
validated.intent.secondary_itemtypes,
contextPack
);
const modelSchemas = Array.isArray(rawResponse.schemas_to_load) ? rawResponse.schemas_to_load : [];
const allSchemas = new Set([...inferredSchemas, ...modelSchemas]);
const schemas_to_load = Array.from(allSchemas).sort();
// Combine errors and warnings
const allErrors = [...validated.errors, ...objectsToResolveValidated.errors];
const allWarnings = [...validated.warnings, ...objectsToResolveValidated.warnings];
const result = {
raw_request: text,
intent: validated.intent,
execution_plan: execution_plan,
objects_to_resolve: objectsToResolveValidated.objects.length > 0 ? objectsToResolveValidated.objects : undefined,
schemas_to_load: schemas_to_load.length > 0 ? schemas_to_load : undefined,
errors: allErrors.length > 0 ? allErrors : undefined,
warnings: allWarnings.length > 0 ? allWarnings : undefined,
validation_log: (allErrors.length > 0 || allWarnings.length > 0) ? {
errors: allErrors,
warnings: allWarnings
} : undefined,
elapsed: calculateElapsed(startTime)
};
return result;
} catch (e) {
coloredLog('Error: ' + e.message);
return createErrorResponse(startTime, ['Plugin error: ' + e.message]);
}
};
/**
* API endpoint: Get all intents (defaults + custom from setting)
*/
this.intents = function(data, req, res) {
try {
const intents = loadIntents();
return {
intents: intents,
count: intents.length,
defaults_count: DEFAULT_INTENTS.length,
custom_count: intents.length - DEFAULT_INTENTS.length
};
} catch (e) {
coloredLog('Error loading intents: ' + e.message);
return {
errors: ['Failed to load intents: ' + e.message],
failedat: 'intent-operator'
};
}
};
this.async = {
default: true
};
this.protected = [];
return self;
}
module.exports = new IntentOperator();