json-object-editor 0.10.670 → 0.10.671
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/_www/ai-widget-test.html +1 -0
- package/_www/intent-operator.html +387 -0
- package/_www/mcp-nav.js +1 -0
- package/docs/JOE_AI_Overview.md +219 -250
- package/docs/schema_summary_guidelines.md +42 -3
- package/package.json +1 -1
- package/server/app-config.js +1 -1
- package/server/modules/Server.js +6 -0
- package/server/plugins/intent-operator.js +704 -0
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
const OpenAI = require("openai");
|
|
2
|
+
const MCP = require("../modules/MCP.js");
|
|
3
|
+
|
|
4
|
+
function IntentOperator() {
|
|
5
|
+
var self = this;
|
|
6
|
+
|
|
7
|
+
function coloredLog(message) {
|
|
8
|
+
console.log(JOE.Utils.color('[intent-operator]', 'plugin', false), message);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function getAPIKey() {
|
|
12
|
+
const setting = JOE.Utils.Settings('OPENAI_API_KEY');
|
|
13
|
+
if (!setting) throw new Error("Missing OPENAI_API_KEY setting");
|
|
14
|
+
return setting;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function newClient() {
|
|
18
|
+
return new OpenAI({ apiKey: getAPIKey() });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Default intents (hard-coded)
|
|
23
|
+
*/
|
|
24
|
+
const DEFAULT_INTENTS = [
|
|
25
|
+
{
|
|
26
|
+
name: 'create_object',
|
|
27
|
+
description: 'User wants to create a new object',
|
|
28
|
+
handoff: 'handoff_worker',
|
|
29
|
+
examples: ['add a task', 'create a project', 'new task called clean my room'],
|
|
30
|
+
operators: ['add', 'create', 'new', 'make'],
|
|
31
|
+
required_information: ['primary_itemtype','name of item']
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'update_object',
|
|
35
|
+
description: 'Modify existing object',
|
|
36
|
+
handoff: 'handoff_worker',
|
|
37
|
+
examples: ['update the status', 'change the name', 'edit task abc123'],
|
|
38
|
+
operators: ['update', 'edit', 'change', 'modify'],
|
|
39
|
+
required_information: ['primary_itemtype', 'object_id']
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'search',
|
|
43
|
+
description: 'Retrieve or list objects',
|
|
44
|
+
handoff: 'handoff_search',
|
|
45
|
+
examples: ['find all projects', 'show me tasks', 'list users'],
|
|
46
|
+
operators: ['find', 'search', 'show', 'list', 'get'],
|
|
47
|
+
required_information: []
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'chat',
|
|
51
|
+
description: 'Conversational/help/explanation',
|
|
52
|
+
handoff: 'handoff_chat',
|
|
53
|
+
examples: ['what can you help me with?', 'explain how this works', 'help me'],
|
|
54
|
+
operators: ['ask', 'help', 'explain', 'what', 'how'],
|
|
55
|
+
required_information: []
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'autofill',
|
|
59
|
+
description: 'Generate field values',
|
|
60
|
+
handoff: 'handoff_autofill',
|
|
61
|
+
examples: ['generate a name', 'fill in the description', 'suggest tags'],
|
|
62
|
+
operators: ['generate', 'fill', 'suggest', 'autofill'],
|
|
63
|
+
required_information: ['primary_itemtype']
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'analyze',
|
|
67
|
+
description: 'Analytical reasoning over objects',
|
|
68
|
+
handoff: 'handoff_worker',
|
|
69
|
+
examples: ['analyze the data', 'summarize projects', 'count tasks'],
|
|
70
|
+
operators: ['analyze', 'summarize', 'count', 'aggregate'],
|
|
71
|
+
required_information: ['primary_itemtype']
|
|
72
|
+
}
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Load and merge intents from setting with defaults
|
|
77
|
+
*/
|
|
78
|
+
function loadIntents() {
|
|
79
|
+
const intents = DEFAULT_INTENTS.slice(); // Copy defaults
|
|
80
|
+
const intentMap = {};
|
|
81
|
+
|
|
82
|
+
// Create map of default intents by name
|
|
83
|
+
intents.forEach(function(intent) {
|
|
84
|
+
intentMap[intent.name] = intent;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Load custom intents from setting
|
|
88
|
+
try {
|
|
89
|
+
const customIntents = JOE.Utils.Settings('ai_intents');
|
|
90
|
+
if (Array.isArray(customIntents) && customIntents.length > 0) {
|
|
91
|
+
customIntents.forEach(function(custom) {
|
|
92
|
+
if (!custom || !custom.name) return; // Skip invalid entries
|
|
93
|
+
|
|
94
|
+
if (intentMap[custom.name]) {
|
|
95
|
+
// Overwrite existing intent
|
|
96
|
+
const index = intents.findIndex(function(i) { return i.name === custom.name; });
|
|
97
|
+
if (index !== -1) {
|
|
98
|
+
intents[index] = Object.assign({}, intents[index], custom, { overwrite: true });
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
// Add new intent
|
|
102
|
+
intents.push(Object.assign({}, custom, { overwrite: false }));
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
} catch (e) {
|
|
107
|
+
coloredLog('Warning: Failed to load ai_intents setting: ' + e.message);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return intents;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Create an empty intent object structure
|
|
115
|
+
*/
|
|
116
|
+
function createEmptyIntent() {
|
|
117
|
+
return {
|
|
118
|
+
intent: null,
|
|
119
|
+
operator: null,
|
|
120
|
+
operator_span: null,
|
|
121
|
+
primary_itemtype: null,
|
|
122
|
+
secondary_itemtypes: [],
|
|
123
|
+
needs_clarification: true,
|
|
124
|
+
questions: [],
|
|
125
|
+
confidence: 0,
|
|
126
|
+
next_step: null
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Calculate elapsed time in seconds with 3 decimal places
|
|
132
|
+
*/
|
|
133
|
+
function calculateElapsed(startTime) {
|
|
134
|
+
return parseFloat(((Date.now() - startTime) / 1000).toFixed(3));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Create a standardized error response
|
|
139
|
+
*/
|
|
140
|
+
function createErrorResponse(startTime, errors, additionalFields) {
|
|
141
|
+
return {
|
|
142
|
+
intent: createEmptyIntent(),
|
|
143
|
+
errors: Array.isArray(errors) ? errors : [errors],
|
|
144
|
+
failedat: 'intent-operator',
|
|
145
|
+
elapsed: calculateElapsed(startTime),
|
|
146
|
+
...additionalFields
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Build context pack from all loaded schemas and MCP tools
|
|
152
|
+
*/
|
|
153
|
+
function buildContextPack() {
|
|
154
|
+
const summaries = JOE.Schemas.summary || {};
|
|
155
|
+
const allowed_itemtypes = Object.keys(summaries).sort();
|
|
156
|
+
const itemtype_fields = {};
|
|
157
|
+
|
|
158
|
+
// Build itemtype_relationships from schema summaries
|
|
159
|
+
const itemtype_relationships = {};
|
|
160
|
+
|
|
161
|
+
allowed_itemtypes.forEach(function(itemtype) {
|
|
162
|
+
const summary = summaries[itemtype];
|
|
163
|
+
if (!summary || !Array.isArray(summary.fields)) {
|
|
164
|
+
itemtype_fields[itemtype] = [];
|
|
165
|
+
itemtype_relationships[itemtype] = [];
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// Extract field names, excluding system-managed fields
|
|
169
|
+
const fieldNames = summary.fields
|
|
170
|
+
.map(function(f) { return f && f.name; })
|
|
171
|
+
.filter(function(name) {
|
|
172
|
+
return name &&
|
|
173
|
+
name !== '_id' &&
|
|
174
|
+
name !== 'itemtype' &&
|
|
175
|
+
name !== 'joeUpdated' &&
|
|
176
|
+
name !== 'created';
|
|
177
|
+
});
|
|
178
|
+
itemtype_fields[itemtype] = fieldNames;
|
|
179
|
+
|
|
180
|
+
// Extract relationships from summary.relationships.outbound
|
|
181
|
+
if (summary.relationships && Array.isArray(summary.relationships.outbound)) {
|
|
182
|
+
const targetSchemas = summary.relationships.outbound.map(function(rel) {
|
|
183
|
+
return rel && rel.targetSchema;
|
|
184
|
+
}).filter(function(schema) {
|
|
185
|
+
return schema && allowed_itemtypes.indexOf(schema) !== -1;
|
|
186
|
+
});
|
|
187
|
+
itemtype_relationships[itemtype] = targetSchemas;
|
|
188
|
+
} else {
|
|
189
|
+
itemtype_relationships[itemtype] = [];
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Get MCP tools from minimal toolset (slimmed, safe subset)
|
|
194
|
+
const mcpToolNames = MCP && MCP.getToolNamesForToolset ? MCP.getToolNamesForToolset('minimal', null) : [];
|
|
195
|
+
const mcpTools = [];
|
|
196
|
+
if (mcpToolNames.length > 0 && MCP && MCP.descriptions) {
|
|
197
|
+
mcpToolNames.forEach(function(toolName) {
|
|
198
|
+
const description = MCP.descriptions[toolName] || '';
|
|
199
|
+
mcpTools.push({
|
|
200
|
+
name: toolName,
|
|
201
|
+
description: description
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
allowed_itemtypes: allowed_itemtypes,
|
|
208
|
+
itemtype_fields: itemtype_fields,
|
|
209
|
+
itemtype_relationships: itemtype_relationships,
|
|
210
|
+
mcp_tools: mcpTools
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Infer schemas_to_load from itemtypes and relationships
|
|
216
|
+
*/
|
|
217
|
+
function inferSchemasToLoad(primaryItemtype, secondaryItemtypes, contextPack) {
|
|
218
|
+
const schemas = new Set();
|
|
219
|
+
|
|
220
|
+
// Add primary and secondary itemtypes
|
|
221
|
+
if (primaryItemtype) {
|
|
222
|
+
schemas.add(primaryItemtype);
|
|
223
|
+
}
|
|
224
|
+
if (Array.isArray(secondaryItemtypes)) {
|
|
225
|
+
secondaryItemtypes.forEach(function(it) {
|
|
226
|
+
if (it) schemas.add(it);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Add all target schemas from relationships
|
|
231
|
+
if (primaryItemtype && contextPack.itemtype_relationships) {
|
|
232
|
+
const relationships = contextPack.itemtype_relationships[primaryItemtype] || [];
|
|
233
|
+
relationships.forEach(function(targetSchema) {
|
|
234
|
+
if (targetSchema) schemas.add(targetSchema);
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Add relationships from secondary itemtypes too
|
|
239
|
+
if (Array.isArray(secondaryItemtypes) && contextPack.itemtype_relationships) {
|
|
240
|
+
secondaryItemtypes.forEach(function(secondary) {
|
|
241
|
+
if (secondary) {
|
|
242
|
+
const relationships = contextPack.itemtype_relationships[secondary] || [];
|
|
243
|
+
relationships.forEach(function(targetSchema) {
|
|
244
|
+
if (targetSchema) schemas.add(targetSchema);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return Array.from(schemas).sort();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Extract JSON from response text (handles markdown code blocks)
|
|
255
|
+
*/
|
|
256
|
+
function extractJsonText(text) {
|
|
257
|
+
if (!text) return null;
|
|
258
|
+
// Try to find JSON in markdown code blocks
|
|
259
|
+
const jsonMatch = text.match(/```(?:json)?\s*(\{[\s\S]*\})\s*```/);
|
|
260
|
+
if (jsonMatch) return jsonMatch[1];
|
|
261
|
+
// Try to find JSON object directly
|
|
262
|
+
const objMatch = text.match(/\{[\s\S]*\}/);
|
|
263
|
+
if (objMatch) return objMatch[0];
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Validate and normalize intent response
|
|
269
|
+
*/
|
|
270
|
+
function validateIntent(rawResponse, contextPack, intents) {
|
|
271
|
+
const errors = [];
|
|
272
|
+
const warnings = [];
|
|
273
|
+
const allowedIntents = intents.map(function(i) { return i.name; });
|
|
274
|
+
const allowedItemtypes = contextPack.allowed_itemtypes || [];
|
|
275
|
+
|
|
276
|
+
// Ensure required fields exist
|
|
277
|
+
const result = {
|
|
278
|
+
intent: rawResponse.intent || null,
|
|
279
|
+
operator: rawResponse.operator || null,
|
|
280
|
+
operator_span: rawResponse.operator_span || null,
|
|
281
|
+
primary_itemtype: rawResponse.primary_itemtype || null,
|
|
282
|
+
secondary_itemtypes: Array.isArray(rawResponse.secondary_itemtypes) ? rawResponse.secondary_itemtypes : [],
|
|
283
|
+
needs_clarification: rawResponse.needs_clarification === true,
|
|
284
|
+
questions: Array.isArray(rawResponse.questions) ? rawResponse.questions : [],
|
|
285
|
+
confidence: typeof rawResponse.confidence === 'number' ? Math.max(0, Math.min(1, rawResponse.confidence)) : 0,
|
|
286
|
+
next_step: rawResponse.next_step || null
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
// Extract extracted_raw for validation (will be moved to next_step.data)
|
|
290
|
+
const extracted_raw = (rawResponse.extracted_raw && typeof rawResponse.extracted_raw === 'object') ? rawResponse.extracted_raw : {};
|
|
291
|
+
|
|
292
|
+
// Validate intent
|
|
293
|
+
if (!result.intent) {
|
|
294
|
+
errors.push('Missing required field: intent');
|
|
295
|
+
} else if (allowedIntents.indexOf(result.intent) === -1) {
|
|
296
|
+
errors.push('Invalid intent: ' + result.intent + ' (must be one of: ' + allowedIntents.join(', ') + ')');
|
|
297
|
+
result.intent = null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Validate primary_itemtype
|
|
301
|
+
if (result.primary_itemtype && allowedItemtypes.indexOf(result.primary_itemtype) === -1) {
|
|
302
|
+
warnings.push('primary_itemtype "' + result.primary_itemtype + '" not in allowed list; setting to null');
|
|
303
|
+
result.primary_itemtype = null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Filter secondary_itemtypes
|
|
307
|
+
const validSecondary = result.secondary_itemtypes.filter(function(it) {
|
|
308
|
+
return allowedItemtypes.indexOf(it) !== -1;
|
|
309
|
+
});
|
|
310
|
+
if (validSecondary.length !== result.secondary_itemtypes.length) {
|
|
311
|
+
warnings.push('Some secondary_itemtypes were filtered out (not in allowed list)');
|
|
312
|
+
}
|
|
313
|
+
result.secondary_itemtypes = validSecondary;
|
|
314
|
+
|
|
315
|
+
// Check semantic consistency between primary_itemtype and secondary_itemtypes
|
|
316
|
+
if (result.primary_itemtype && result.secondary_itemtypes.length > 0 && contextPack.itemtype_relationships) {
|
|
317
|
+
const primaryRelationships = contextPack.itemtype_relationships[result.primary_itemtype] || [];
|
|
318
|
+
const mismatches = result.secondary_itemtypes.filter(function(secondary) {
|
|
319
|
+
// Check if primary can reference this secondary itemtype
|
|
320
|
+
return primaryRelationships.indexOf(secondary) === -1;
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
if (mismatches.length > 0) {
|
|
324
|
+
warnings.push('Semantic mismatch: ' + result.primary_itemtype + ' does not reference ' + mismatches.join(', ') +
|
|
325
|
+
' (valid references: ' + (primaryRelationships.length > 0 ? primaryRelationships.join(', ') : 'none') + ')');
|
|
326
|
+
// Force clarification when semantic mismatch detected
|
|
327
|
+
if (!result.needs_clarification) {
|
|
328
|
+
result.needs_clarification = true;
|
|
329
|
+
}
|
|
330
|
+
if (result.questions.length === 0) {
|
|
331
|
+
result.questions.push('Did you mean a different itemtype? ' + result.primary_itemtype +
|
|
332
|
+
' typically relates to ' + (primaryRelationships.length > 0 ? primaryRelationships.join(', ') : 'nothing') +
|
|
333
|
+
', but you mentioned ' + mismatches.join(', ') + '.');
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Filter extracted fields by field whitelist
|
|
339
|
+
let validatedExtracted = {};
|
|
340
|
+
if (result.primary_itemtype && contextPack.itemtype_fields[result.primary_itemtype]) {
|
|
341
|
+
const allowedFields = contextPack.itemtype_fields[result.primary_itemtype];
|
|
342
|
+
const rawKeys = Object.keys(extracted_raw);
|
|
343
|
+
rawKeys.forEach(function(key) {
|
|
344
|
+
if (allowedFields.indexOf(key) === -1) {
|
|
345
|
+
warnings.push('Field "' + key + '" in extracted_raw not in whitelist for ' + result.primary_itemtype);
|
|
346
|
+
} else {
|
|
347
|
+
validatedExtracted[key] = extracted_raw[key];
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
} else {
|
|
351
|
+
validatedExtracted = extracted_raw;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Set next_step based on intent if missing
|
|
355
|
+
if (result.intent && !result.next_step) {
|
|
356
|
+
const intentDef = intents.find(function(i) { return i.name === result.intent; });
|
|
357
|
+
if (intentDef && intentDef.handoff) {
|
|
358
|
+
// Convert handoff string to structured object
|
|
359
|
+
result.next_step = {
|
|
360
|
+
handler: intentDef.handoff,
|
|
361
|
+
prompt: null,
|
|
362
|
+
data: null
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Validate next_step structure if present
|
|
368
|
+
if (result.next_step) {
|
|
369
|
+
if (typeof result.next_step === 'string') {
|
|
370
|
+
// Legacy string format - convert to structured
|
|
371
|
+
result.next_step = {
|
|
372
|
+
handler: result.next_step,
|
|
373
|
+
prompt: null,
|
|
374
|
+
data: null
|
|
375
|
+
};
|
|
376
|
+
} else if (typeof result.next_step === 'object' && result.next_step !== null) {
|
|
377
|
+
// Ensure structured format has required fields
|
|
378
|
+
if (!result.next_step.handler) {
|
|
379
|
+
result.next_step.handler = null;
|
|
380
|
+
}
|
|
381
|
+
if (result.next_step.prompt === undefined) {
|
|
382
|
+
result.next_step.prompt = null;
|
|
383
|
+
}
|
|
384
|
+
if (result.next_step.data === undefined) {
|
|
385
|
+
result.next_step.data = null;
|
|
386
|
+
}
|
|
387
|
+
} else {
|
|
388
|
+
result.next_step = null;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Move extracted_raw to next_step.data (if next_step exists)
|
|
393
|
+
if (result.next_step && Object.keys(validatedExtracted).length > 0) {
|
|
394
|
+
// Merge with existing data if present, otherwise set to extracted fields
|
|
395
|
+
if (result.next_step.data && typeof result.next_step.data === 'object') {
|
|
396
|
+
result.next_step.data = Object.assign({}, result.next_step.data, validatedExtracted);
|
|
397
|
+
} else {
|
|
398
|
+
result.next_step.data = validatedExtracted;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
intent: result,
|
|
404
|
+
errors: errors,
|
|
405
|
+
warnings: warnings
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Validate objects_to_resolve array
|
|
411
|
+
*/
|
|
412
|
+
function validateObjectsToResolve(objectsToResolve, contextPack, primaryItemtype) {
|
|
413
|
+
const errors = [];
|
|
414
|
+
const warnings = [];
|
|
415
|
+
const validated = [];
|
|
416
|
+
|
|
417
|
+
if (!Array.isArray(objectsToResolve)) {
|
|
418
|
+
return { objects: [], errors: ['objects_to_resolve must be an array'], warnings: [] };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
objectsToResolve.forEach(function(obj, index) {
|
|
422
|
+
if (!obj || typeof obj !== 'object') {
|
|
423
|
+
warnings.push('objects_to_resolve[' + index + '] is not a valid object');
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const itemtype = obj.itemtype;
|
|
428
|
+
const name = obj.name;
|
|
429
|
+
const field = obj.field;
|
|
430
|
+
|
|
431
|
+
if (!itemtype || typeof itemtype !== 'string') {
|
|
432
|
+
errors.push('objects_to_resolve[' + index + '] missing or invalid itemtype');
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (!name || typeof name !== 'string') {
|
|
437
|
+
errors.push('objects_to_resolve[' + index + '] missing or invalid name');
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (!field || typeof field !== 'string') {
|
|
442
|
+
errors.push('objects_to_resolve[' + index + '] missing or invalid field');
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Validate itemtype exists
|
|
447
|
+
if (contextPack.allowed_itemtypes.indexOf(itemtype) === -1) {
|
|
448
|
+
warnings.push('objects_to_resolve[' + index + '] itemtype "' + itemtype + '" not in allowed list');
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Validate field exists and is a reference field for primary_itemtype
|
|
452
|
+
if (primaryItemtype && contextPack.itemtype_fields[primaryItemtype]) {
|
|
453
|
+
const allowedFields = contextPack.itemtype_fields[primaryItemtype];
|
|
454
|
+
if (allowedFields.indexOf(field) === -1) {
|
|
455
|
+
warnings.push('objects_to_resolve[' + index + '] field "' + field + '" not in allowed fields for ' + primaryItemtype);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Build validated object
|
|
460
|
+
const validatedObj = {
|
|
461
|
+
itemtype: itemtype,
|
|
462
|
+
name: name,
|
|
463
|
+
field: field,
|
|
464
|
+
operation: obj.operation || 'link',
|
|
465
|
+
purpose: obj.purpose || 'Resolve ' + itemtype + ' name "' + name + '" to _id for ' + field + ' field'
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
validated.push(validatedObj);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
return {
|
|
472
|
+
objects: validated,
|
|
473
|
+
errors: errors,
|
|
474
|
+
warnings: warnings
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Main handler: classify intent from natural language
|
|
480
|
+
*/
|
|
481
|
+
this.default = async function(data, req, res) {
|
|
482
|
+
const startTime = Date.now();
|
|
483
|
+
try {
|
|
484
|
+
const text = (data && data.text) || '';
|
|
485
|
+
if (!text || typeof text !== 'string' || !text.trim()) {
|
|
486
|
+
return createErrorResponse(startTime, ['Missing or empty "text" parameter']);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Build context pack
|
|
490
|
+
const contextPack = buildContextPack();
|
|
491
|
+
|
|
492
|
+
// Check if context pack is empty
|
|
493
|
+
if (!contextPack.allowed_itemtypes || contextPack.allowed_itemtypes.length === 0) {
|
|
494
|
+
return createErrorResponse(startTime, ['No schemas loaded - context pack is empty']);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
coloredLog('Context pack: ' + contextPack.allowed_itemtypes.length + ' itemtypes');
|
|
498
|
+
|
|
499
|
+
// Log prompt size for debugging
|
|
500
|
+
const contextPackSize = JSON.stringify(contextPack.itemtype_fields).length;
|
|
501
|
+
coloredLog('Context pack size: ' + contextPackSize + ' characters');
|
|
502
|
+
|
|
503
|
+
// Load intents (used for both prompt and validation)
|
|
504
|
+
const intents = loadIntents();
|
|
505
|
+
const allowedIntents = intents.map(function(i) { return i.name; });
|
|
506
|
+
|
|
507
|
+
// Build intent descriptions for prompt (including required_information)
|
|
508
|
+
const intentDescriptions = intents.map(function(i) {
|
|
509
|
+
let desc = i.name + ': ' + i.description;
|
|
510
|
+
if (i.required_information && Array.isArray(i.required_information) && i.required_information.length > 0) {
|
|
511
|
+
desc += ' (requires: ' + i.required_information.join(', ') + ')';
|
|
512
|
+
}
|
|
513
|
+
return desc;
|
|
514
|
+
}).join('\n');
|
|
515
|
+
|
|
516
|
+
// Build MCP tools description for prompt
|
|
517
|
+
let mcpToolsText = '';
|
|
518
|
+
if (contextPack.mcp_tools && contextPack.mcp_tools.length > 0) {
|
|
519
|
+
mcpToolsText = '\nAvailable MCP tools:\n' +
|
|
520
|
+
contextPack.mcp_tools.map(function(tool) {
|
|
521
|
+
return '- ' + tool.name + ': ' + tool.description;
|
|
522
|
+
}).join('\n') + '\n';
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Build relationships description for prompt
|
|
526
|
+
let relationshipsText = '';
|
|
527
|
+
if (contextPack.itemtype_relationships) {
|
|
528
|
+
relationshipsText = '\nItemtype relationships (which itemtypes can reference which):\n';
|
|
529
|
+
Object.keys(contextPack.itemtype_relationships).forEach(function(itemtype) {
|
|
530
|
+
const refs = contextPack.itemtype_relationships[itemtype];
|
|
531
|
+
if (refs && refs.length > 0) {
|
|
532
|
+
relationshipsText += '- ' + itemtype + ' can reference: ' + refs.join(', ') + '\n';
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
relationshipsText += '\n';
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Build prompt
|
|
539
|
+
const prompt = [
|
|
540
|
+
'You are JOE (Json Object Editor) Intent Classifier.',
|
|
541
|
+
'',
|
|
542
|
+
'Task: Analyze the user input and return a structured intent JSON object.',
|
|
543
|
+
'',
|
|
544
|
+
'Allowed intents:',
|
|
545
|
+
intentDescriptions,
|
|
546
|
+
'',
|
|
547
|
+
'Intent names: ' + allowedIntents.join(', '),
|
|
548
|
+
'',
|
|
549
|
+
'Available itemtypes: ' + contextPack.allowed_itemtypes.join(', '),
|
|
550
|
+
'',
|
|
551
|
+
'For each itemtype, allowed fields:',
|
|
552
|
+
JSON.stringify(contextPack.itemtype_fields, null, 2),
|
|
553
|
+
relationshipsText,
|
|
554
|
+
mcpToolsText,
|
|
555
|
+
'Output format (JSON only, no markdown):',
|
|
556
|
+
'{',
|
|
557
|
+
' "intent": "one of the allowed intents",',
|
|
558
|
+
' "operator": "normalized verb label (e.g., add, create, edit, find, show)",',
|
|
559
|
+
' "operator_span": "literal phrase from input (optional)",',
|
|
560
|
+
' "primary_itemtype": "main object type being acted on (or null)",',
|
|
561
|
+
' "secondary_itemtypes": ["related object types"],',
|
|
562
|
+
' "needs_clarification": false,',
|
|
563
|
+
' "questions": [],',
|
|
564
|
+
' "confidence": 0.0,',
|
|
565
|
+
' "next_step": { "handler": "handler_name", "prompt": "optional prompt text", "data": { "field_name": "extracted value" } },',
|
|
566
|
+
' "execution_plan": ["step 1", "step 2", "step 3"],',
|
|
567
|
+
' "objects_to_resolve": [',
|
|
568
|
+
' { "itemtype": "project", "name": "home", "field": "project", "operation": "link", "purpose": "Resolve project name to _id" }',
|
|
569
|
+
' ],',
|
|
570
|
+
' "schemas_to_load": ["task", "project", "status"]',
|
|
571
|
+
'}',
|
|
572
|
+
'',
|
|
573
|
+
'Rules:',
|
|
574
|
+
'- Choose intent from allowed list only.',
|
|
575
|
+
'- Choose itemtypes from available list only.',
|
|
576
|
+
'- 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.',
|
|
577
|
+
'- 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\'?"',
|
|
578
|
+
'- 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.',
|
|
579
|
+
'- Only extract fields that exist in the field whitelist for the primary_itemtype.',
|
|
580
|
+
'- Put extracted field values in next_step.data (as an object with field names as keys).',
|
|
581
|
+
'- 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.',
|
|
582
|
+
'- 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.',
|
|
583
|
+
'- Only add to objects_to_resolve if the value looks like a name (not a CUID - CUIDs are long alphanumeric strings like "clx123abc456def789").',
|
|
584
|
+
'- 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).',
|
|
585
|
+
'- Set needs_clarification=true if ambiguous or missing required info (check required_information for each intent).',
|
|
586
|
+
'- 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.',
|
|
587
|
+
'- next_step should be a structured object with handler (the handoff handler name), prompt (optional descriptive text), and data (object with extracted field values).',
|
|
588
|
+
'- Always generate execution_plan as an array of strings describing the steps needed.',
|
|
589
|
+
'- 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").',
|
|
590
|
+
'- Use real schema names and field names from the context pack.',
|
|
591
|
+
'- 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").',
|
|
592
|
+
'- Even if clarification is needed, describe what you think the plan should be.',
|
|
593
|
+
'',
|
|
594
|
+
'User input: ' + text
|
|
595
|
+
].join('\n');
|
|
596
|
+
|
|
597
|
+
// Call OpenAI
|
|
598
|
+
// Using gpt-4o-mini as it's proven fast and available (gpt-5-nano may not exist yet)
|
|
599
|
+
const openai = newClient();
|
|
600
|
+
const apiStartTime = Date.now();
|
|
601
|
+
const response = await openai.responses.create({
|
|
602
|
+
//model: 'gpt-4o-mini',
|
|
603
|
+
model: 'gpt-4.1-nano',
|
|
604
|
+
|
|
605
|
+
instructions: 'You are a JSON-only output assistant. Return only valid JSON, no markdown, no explanations.',
|
|
606
|
+
input: prompt
|
|
607
|
+
});
|
|
608
|
+
const apiElapsed = ((Date.now() - apiStartTime) / 1000).toFixed(3);
|
|
609
|
+
coloredLog('OpenAI API call took: ' + apiElapsed + ' seconds');
|
|
610
|
+
|
|
611
|
+
const rawText = response.output_text || '';
|
|
612
|
+
const jsonText = extractJsonText(rawText);
|
|
613
|
+
|
|
614
|
+
if (!jsonText) {
|
|
615
|
+
return createErrorResponse(startTime, ['Failed to extract JSON from model response'], {
|
|
616
|
+
raw_response: rawText.substring(0, 500)
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
let rawResponse;
|
|
621
|
+
try {
|
|
622
|
+
rawResponse = JSON.parse(jsonText);
|
|
623
|
+
} catch (e) {
|
|
624
|
+
return createErrorResponse(startTime, ['Failed to parse JSON: ' + e.message], {
|
|
625
|
+
raw_response: rawText.substring(0, 500)
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Validate and normalize (using intents loaded above)
|
|
630
|
+
const validated = validateIntent(rawResponse, contextPack, intents);
|
|
631
|
+
|
|
632
|
+
// Extract execution_plan from raw response (not from intent object)
|
|
633
|
+
const execution_plan = Array.isArray(rawResponse.execution_plan) ? rawResponse.execution_plan : [];
|
|
634
|
+
|
|
635
|
+
// Validate objects_to_resolve
|
|
636
|
+
const objectsToResolveRaw = Array.isArray(rawResponse.objects_to_resolve) ? rawResponse.objects_to_resolve : [];
|
|
637
|
+
const objectsToResolveValidated = validateObjectsToResolve(objectsToResolveRaw, contextPack, validated.intent.primary_itemtype);
|
|
638
|
+
|
|
639
|
+
// Infer schemas_to_load and merge with model-provided ones
|
|
640
|
+
const inferredSchemas = inferSchemasToLoad(
|
|
641
|
+
validated.intent.primary_itemtype,
|
|
642
|
+
validated.intent.secondary_itemtypes,
|
|
643
|
+
contextPack
|
|
644
|
+
);
|
|
645
|
+
const modelSchemas = Array.isArray(rawResponse.schemas_to_load) ? rawResponse.schemas_to_load : [];
|
|
646
|
+
const allSchemas = new Set([...inferredSchemas, ...modelSchemas]);
|
|
647
|
+
const schemas_to_load = Array.from(allSchemas).sort();
|
|
648
|
+
|
|
649
|
+
// Combine errors and warnings
|
|
650
|
+
const allErrors = [...validated.errors, ...objectsToResolveValidated.errors];
|
|
651
|
+
const allWarnings = [...validated.warnings, ...objectsToResolveValidated.warnings];
|
|
652
|
+
|
|
653
|
+
const result = {
|
|
654
|
+
raw_request: text,
|
|
655
|
+
intent: validated.intent,
|
|
656
|
+
execution_plan: execution_plan,
|
|
657
|
+
objects_to_resolve: objectsToResolveValidated.objects.length > 0 ? objectsToResolveValidated.objects : undefined,
|
|
658
|
+
schemas_to_load: schemas_to_load.length > 0 ? schemas_to_load : undefined,
|
|
659
|
+
errors: allErrors.length > 0 ? allErrors : undefined,
|
|
660
|
+
warnings: allWarnings.length > 0 ? allWarnings : undefined,
|
|
661
|
+
validation_log: (allErrors.length > 0 || allWarnings.length > 0) ? {
|
|
662
|
+
errors: allErrors,
|
|
663
|
+
warnings: allWarnings
|
|
664
|
+
} : undefined,
|
|
665
|
+
elapsed: calculateElapsed(startTime)
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
return result;
|
|
669
|
+
|
|
670
|
+
} catch (e) {
|
|
671
|
+
coloredLog('Error: ' + e.message);
|
|
672
|
+
return createErrorResponse(startTime, ['Plugin error: ' + e.message]);
|
|
673
|
+
}
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* API endpoint: Get all intents (defaults + custom from setting)
|
|
678
|
+
*/
|
|
679
|
+
this.intents = function(data, req, res) {
|
|
680
|
+
try {
|
|
681
|
+
const intents = loadIntents();
|
|
682
|
+
return {
|
|
683
|
+
intents: intents,
|
|
684
|
+
count: intents.length,
|
|
685
|
+
defaults_count: DEFAULT_INTENTS.length,
|
|
686
|
+
custom_count: intents.length - DEFAULT_INTENTS.length
|
|
687
|
+
};
|
|
688
|
+
} catch (e) {
|
|
689
|
+
coloredLog('Error loading intents: ' + e.message);
|
|
690
|
+
return {
|
|
691
|
+
errors: ['Failed to load intents: ' + e.message],
|
|
692
|
+
failedat: 'intent-operator'
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
this.async = {
|
|
698
|
+
default: true
|
|
699
|
+
};
|
|
700
|
+
this.protected = [];
|
|
701
|
+
return self;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
module.exports = new IntentOperator();
|