json-object-editor 0.10.505 β 0.10.507
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/CHANGELOG.md +13 -0
- package/_www/mcp-prompt.html +123 -9
- package/_www/mcp-test.html +21 -2
- package/package.json +1 -1
- package/readme.md +17 -2
- package/server/config/ai-global.json +34 -0
- package/server/modules/Cache.js +180 -4
- package/server/modules/MCP.js +65 -4
- package/server/modules/Server.js +37 -6
- package/server/schemas/instance.js +1 -1
- package/server/schemas/task.js +2 -1
- package/server/schemas/user.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
## CHANGELOG
|
|
2
2
|
|
|
3
|
+
|
|
4
|
+
### 0.10.507
|
|
5
|
+
|
|
6
|
+
- Server: Added fuzzy search support via `$fuzzy` in `JOE.Cache.search(query)` with weighted default fields (name 0.6, info 0.3, description 0.1). Implemented a lightweight trigram Jaccard scorer. Supports `q`, `fields`, `threshold`, `limit`, `offset`, `highlight`, `minQueryLength`.
|
|
7
|
+
- API: `/API/search` now safely parses JSON (no eval), and accepts `mode=fuzzy` plus query params (`q`, `fields`, `threshold`, `limit`, `offset`, `highlight`). Backward compatible; exact search remains default.
|
|
8
|
+
- MCP: New tool `fuzzySearch` for typo-tolerant free text across weighted fields; updated `search` description to clarify exact vs fuzzy usage.
|
|
9
|
+
- Docs: Updated README with fuzzy API and MCP usage examples.
|
|
10
|
+
|
|
11
|
+
|
|
3
12
|
### 0.10.500
|
|
4
13
|
500 - MCP integration (initial)
|
|
5
14
|
- MCP core module with JSON-RPC 2.0 endpoint (/mcp) protected by auth
|
|
@@ -34,6 +43,10 @@
|
|
|
34
43
|
- Extracted shared nav to /JsonObjectEditor/_www/mcp-nav.js and used across Test/Export/Privacy/Terms
|
|
35
44
|
- Nav now shows instance name/version/host and links to Privacy/Terms (named windows)
|
|
36
45
|
|
|
46
|
+
506 - Agent context + search count
|
|
47
|
+
- Added concise JOE context block for agent prompts (schema-first decisions, IDs, query strategy, writes, autonomy)
|
|
48
|
+
- search now supports { countOnly: true } to return only a count for large-result checks
|
|
49
|
+
|
|
37
50
|
### 0.10.500
|
|
38
51
|
500 - MCP integration (initial)
|
|
39
52
|
- MCP core module with JSON-RPC 2.0 endpoint (/mcp) protected by auth
|
package/_www/mcp-prompt.html
CHANGED
|
@@ -15,15 +15,129 @@
|
|
|
15
15
|
<script src="/JsonObjectEditor/_www/mcp-nav.js"></script>
|
|
16
16
|
<h1>Starter Agent Instructions</h1>
|
|
17
17
|
<div class="small">Copy into your Custom GPT or Assistant system prompt. Adjust as needed.</div>
|
|
18
|
-
<textarea readonly id="prompt"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
18
|
+
<textarea readonly id="prompt">You are a schema-aware data assistant for JOE. Use only the provided tools.
|
|
19
|
+
|
|
20
|
+
π©βπ» Dev Mode Behavior:
|
|
21
|
+
If `devMode` is true, narrate your actions as you go (e.g., "Calling getSchema for 'client'...").
|
|
22
|
+
|
|
23
|
+
π― Autonomy:
|
|
24
|
+
- Act autonomously β call tools immediately if you know whatβs needed.
|
|
25
|
+
- No permission prompts, no menus β pick the best action.
|
|
26
|
+
- Ask for clarification only if a required parameter is missing.
|
|
27
|
+
- On session start: `hydrate {}`, then preload schemas.
|
|
28
|
+
- Never expose secrets/tokens. Always confirm before `saveObject`.
|
|
29
|
+
|
|
30
|
+
π§ Schema Awareness:
|
|
31
|
+
- Always hydrate first (`hydrate {}`), then `listSchemas` + `getSchema` for each.
|
|
32
|
+
- Keep a `schemaMap` in memory for validation.
|
|
33
|
+
- Validate fields before constructing objects or saving.
|
|
34
|
+
|
|
35
|
+
π Query Strategy:
|
|
36
|
+
- Default: `search { "query": { "itemtype": "<schema>" }, "limit": 10 }`
|
|
37
|
+
- For authoritative data: add `"source": "storage"`
|
|
38
|
+
- For large queries: `search { countOnly: true }` first
|
|
39
|
+
- Use filters to narrow results; paginate when over 25.
|
|
40
|
+
|
|
41
|
+
π¦ References:
|
|
42
|
+
- References are always string IDs.
|
|
43
|
+
- Use `getObject` to resolve related objects.
|
|
44
|
+
- Never assume a reference includes a name/label.
|
|
45
|
+
|
|
46
|
+
π Object Flow:
|
|
47
|
+
- `getObject { _id, schema }` β returns raw object
|
|
48
|
+
- `saveObject { object }` β only on explicit instruction
|
|
49
|
+
- Always reflect the final object before saving; confirm fields match schema
|
|
50
|
+
|
|
51
|
+
π Typical Sequence:
|
|
52
|
+
1. `hydrate {}`
|
|
53
|
+
2. `listSchemas {}`
|
|
54
|
+
3. For each needed schema: `getSchema { name }`
|
|
55
|
+
4. `search { query: { itemtype }, limit: 10 }`
|
|
56
|
+
5. `getObject { _id, schema }` (if user selects)
|
|
57
|
+
6. `saveObject { object }` (only after confirmation)
|
|
58
|
+
|
|
59
|
+
π Save Behavior:
|
|
60
|
+
- Never auto-save
|
|
61
|
+
- Confirm with user
|
|
62
|
+
- Ensure object has `itemtype` and only valid fields
|
|
63
|
+
(see FULL OBJECT SAVE REQUIREMENT)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
Summary (Tips for Success)
|
|
67
|
+
The agent identifies users by ID before querying, handles large payloads automatically, and if searches fail, it retrieves a cached list of records and filters them locally. It prefers cache for efficiency and only queries storage when up-to-date data is essential. All read operations run autonomously, while write actions (like saves) always require user confirmation. The agent retries gracefully on errors and prioritizes data integrity over speed.
|
|
68
|
+
|
|
69
|
+
π§ Tips for Success
|
|
70
|
+
|
|
71
|
+
1. Identify the User by ID First
|
|
72
|
+
Before making any queries, retrieve the userβs _id from the user schema using name, username, or email.
|
|
73
|
+
Use that ID for all task, project, or goal lookups.
|
|
74
|
+
|
|
75
|
+
2. Handle Large Payloads Automatically
|
|
76
|
+
If a query risks exceeding limits or triggers a ResponseTooLargeError, immediately retry with "countOnly": true to assess scope.
|
|
77
|
+
Then paginate (25β50 items) or filter (e.g., overdue, recent).
|
|
78
|
+
|
|
79
|
+
3. Fallback When Search Returns No Results
|
|
80
|
+
If a search returns no results, retrieve a broad list of items from cache (for example, limit: 200, source: "cache"). Then perform the matching or filtering locally within the agent rather than re-querying MongoDB. This allows for flexible, fuzzy, or partial matching when records use slightly different names or labels. Use cached data whenever possible to reduce database load and latency.
|
|
81
|
+
|
|
82
|
+
4. Prefer Cached Results Unless Accuracy Requires Storage
|
|
83
|
+
Use "source": "cache" by default for most queries to reduce database load.
|
|
84
|
+
Only use "source": "storage" when authoritative, up-to-date data is essential (e.g., post-save validations).
|
|
85
|
+
|
|
86
|
+
5. Graceful Degradation
|
|
87
|
+
When tool calls fail or responses are truncated, automatically reduce depth, limit, or flatten and retry.
|
|
88
|
+
Always return partial results with context rather than halting.
|
|
89
|
+
(see hydration utilization)
|
|
90
|
+
|
|
91
|
+
6. Resolve References Contextually
|
|
92
|
+
When referencing linked data (projects, businesses, etc.), resolve the _id via schema lookup first.
|
|
93
|
+
Never assume a reference includes the name or label directly.
|
|
94
|
+
|
|
95
|
+
π§ Hydration Utilization
|
|
96
|
+
|
|
97
|
+
Hydration provides preloaded datasets such as status, tag, dataset, and other shared entities.
|
|
98
|
+
These hydrated objects should always be used first when resolving references or labels before performing new searches.
|
|
99
|
+
|
|
100
|
+
When the agent needs a status, tag, or other labeling entity, it should:
|
|
101
|
+
|
|
102
|
+
Check the hydrated cache (for example, _joe.Data.status, _joe.Data.tag) for matches.
|
|
103
|
+
|
|
104
|
+
Filter by datasets that include the relevant schema, such as "task" or "project".
|
|
105
|
+
|
|
106
|
+
Use the hydrated recordβs _id directly when creating or linking new objects.
|
|
107
|
+
|
|
108
|
+
Only query storage if the hydration cache is missing or incomplete.
|
|
109
|
+
|
|
110
|
+
This approach ensures accuracy and performance by aligning all lookups with canonical, workflow-defined entities already present in memory.
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
AUTONOMY & SAFETY
|
|
115
|
+
|
|
116
|
+
Autonomous Read Operations
|
|
117
|
+
The agent acts independently for all read-only actions. It should execute hydrate, listSchemas, getSchema, search (cache or storage), getObject, count, and any fallback pagination or list retrieval without asking for user confirmation. These operations are non-destructive and safe for full autonomy.
|
|
118
|
+
|
|
119
|
+
Explicit Confirmation for Writes
|
|
120
|
+
Any operation that modifies or creates data must be confirmed by the user before execution. This includes saveObject or any update or creation that writes to storage. Before saving, validate the object against its schema and reflect it back to the user for confirmation.
|
|
121
|
+
|
|
122
|
+
Fail-Safe Execution
|
|
123
|
+
If a tool call fails, retry safely by reducing depth or limit, or switching to cache. Never guess or infer field values or relationships. Always prioritize data integrity over speed for write operations.
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
FULL OBJECT SAVE REQUIREMENT
|
|
127
|
+
|
|
128
|
+
When performing a saveObject operation, always include the complete object rather than only the updated fields. Partial updates are not supported by the JOE MCP interface and will fail validation.
|
|
129
|
+
|
|
130
|
+
Before saving:
|
|
131
|
+
|
|
132
|
+
- Retrieve the latest version of the object from storage using getObject.
|
|
133
|
+
- Merge any updates into that object in memory.
|
|
134
|
+
- Ensure the itemtype, _id, and all schema-required fields are present.
|
|
135
|
+
- Update the joeUpdated field to the current UTC time in ISO 8601 format with milliseconds.
|
|
136
|
+
- Validate that all field types (for example, priority as a string, date fields in ISO format, booleans for completion) match the schema.
|
|
137
|
+
- Save the complete object back using saveObject.
|
|
138
|
+
|
|
139
|
+
Why this matters:
|
|
140
|
+
The JOE MCP interface replaces the full document on save rather than applying a patch. If required fields or key metadata (such as itemtype or project references) are missing, the record can become invalid or incomplete. Full-object saves guarantee schema integrity, consistent relationships, and a complete audit trail.</textarea>
|
|
27
141
|
</body>
|
|
28
142
|
</html>
|
|
29
143
|
|
package/_www/mcp-test.html
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
.small{font-size:12px;color:#666}
|
|
15
15
|
.bad{color:#b00020}
|
|
16
16
|
.good{color:#0a7d00}
|
|
17
|
+
.preset{margin:8px 0;}
|
|
17
18
|
</style>
|
|
18
19
|
</head>
|
|
19
20
|
<body>
|
|
@@ -33,6 +34,12 @@
|
|
|
33
34
|
<select id="tool"></select>
|
|
34
35
|
<pre id="toolInfo"></pre>
|
|
35
36
|
|
|
37
|
+
<h3>Presets</h3>
|
|
38
|
+
<div class="preset">
|
|
39
|
+
<button id="presetFuzzyUser">fuzzySearch users: q="corey hadden"</button>
|
|
40
|
+
<button id="presetFuzzyHouse">fuzzySearch houses: q="backyard"</button>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
36
43
|
<h3>Call JSON-RPC</h3>
|
|
37
44
|
<label for="params">Params (JSON)</label>
|
|
38
45
|
<textarea id="params">{}</textarea>
|
|
@@ -54,6 +61,8 @@
|
|
|
54
61
|
const callBtn = $('call');
|
|
55
62
|
const callStatus = $('callStatus');
|
|
56
63
|
const result = $('result');
|
|
64
|
+
const presetFuzzyUser = $('presetFuzzyUser');
|
|
65
|
+
const presetFuzzyHouse = $('presetFuzzyHouse');
|
|
57
66
|
|
|
58
67
|
// Try to infer base from window location
|
|
59
68
|
base.value = base.value || (location.origin);
|
|
@@ -84,8 +93,8 @@
|
|
|
84
93
|
try{
|
|
85
94
|
const url = base.value.replace(/\/$/,'') + '/.well-known/mcp/manifest.json';
|
|
86
95
|
manifest = await fetchJSON(url);
|
|
87
|
-
|
|
88
|
-
|
|
96
|
+
// Instance info handled by shared nav script
|
|
97
|
+
(manifest.tools||[]).forEach(t=>{
|
|
89
98
|
const opt=document.createElement('option');
|
|
90
99
|
opt.value=t.name; opt.textContent=t.name;
|
|
91
100
|
toolSel.appendChild(opt);
|
|
@@ -114,6 +123,16 @@
|
|
|
114
123
|
}
|
|
115
124
|
toolSel.onchange = renderToolInfo;
|
|
116
125
|
|
|
126
|
+
presetFuzzyUser.onclick = function(){
|
|
127
|
+
toolSel.value = 'fuzzySearch';
|
|
128
|
+
renderToolInfo();
|
|
129
|
+
params.value = JSON.stringify({ q: 'corey hadden', filters: { itemtype: 'user' }, threshold: 0.5 }, null, 2);
|
|
130
|
+
};
|
|
131
|
+
presetFuzzyHouse.onclick = function(){
|
|
132
|
+
toolSel.value = 'fuzzySearch';
|
|
133
|
+
renderToolInfo();
|
|
134
|
+
params.value = JSON.stringify({ q: 'backyard', filters: { itemtype: 'house' }, threshold: 0.5 }, null, 2);
|
|
135
|
+
};
|
|
117
136
|
|
|
118
137
|
callBtn.onclick = async function(){
|
|
119
138
|
setStatus(callStatus, 'Calling...', null);
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -45,9 +45,13 @@ JOE is software that allows you to manage data models via JSON objects. There ar
|
|
|
45
45
|
- Tools
|
|
46
46
|
- `listSchemas(name?)`, `getSchema(name)`
|
|
47
47
|
- `getObject(_id, schema?)` (supports optional `flatten` and `depth`)
|
|
48
|
-
- `search` (
|
|
48
|
+
- `search` (exact): unified tool for cache and storage
|
|
49
49
|
- Params: `{ schema?, query?, ids?, source?: 'cache'|'storage', limit?, flatten?, depth? }`
|
|
50
|
-
- Defaults to cache across all collections; add `schema` to filter; set `source:"storage"` to query a specific schema in the DB.
|
|
50
|
+
- Defaults to cache across all collections; add `schema` to filter; set `source:"storage"` to query a specific schema in the DB. Use `fuzzySearch` for typo-tolerant free text.
|
|
51
|
+
- `fuzzySearch` (typo-tolerant free text across weighted fields)
|
|
52
|
+
- Params: `{ schema?, q, filters?, fields?, threshold?, limit?, offset?, highlight?, minQueryLength? }`
|
|
53
|
+
- Defaults: `fields` resolved from schema `searchables` (plural) if present; otherwise weights `name:0.6, info:0.3, description:0.1`. `threshold:0.5`, `limit:50`, `minQueryLength:2`.
|
|
54
|
+
- Returns: `{ items, count }`. Each item may include `_score` (0..1) and `_matches` when `highlight` is true.
|
|
51
55
|
- `saveObject({ object })`
|
|
52
56
|
|
|
53
57
|
- Quick tests (PowerShell)
|
|
@@ -76,6 +80,11 @@ JOE is software that allows you to manage data models via JSON objects. There ar
|
|
|
76
80
|
$body = @{ jsonrpc="2.0"; id="3"; method="search"; params=@{ query=@{ itemtype="<schemaName>" }; limit=10 } } | ConvertTo-Json -Depth 10
|
|
77
81
|
Invoke-RestMethod -Method Post -Uri "$base/mcp" -Headers $h -Body $body
|
|
78
82
|
```
|
|
83
|
+
- fuzzySearch (cache):
|
|
84
|
+
```powershell
|
|
85
|
+
$body = @{ jsonrpc="2.0"; id="6"; method="fuzzySearch"; params=@{ schema="<schemaName>"; q="st paal"; threshold=0.35; limit=10 } } | ConvertTo-Json -Depth 10
|
|
86
|
+
Invoke-RestMethod -Method Post -Uri "$base/mcp" -Headers $h -Body $body
|
|
87
|
+
```
|
|
79
88
|
- search (storage):
|
|
80
89
|
```powershell
|
|
81
90
|
$body = @{ jsonrpc="2.0"; id="4"; method="search"; params=@{ schema="<schemaName>"; source="storage"; query=@{ }; limit=10 } } | ConvertTo-Json -Depth 10
|
|
@@ -105,6 +114,12 @@ JOE is software that allows you to manage data models via JSON objects. There ar
|
|
|
105
114
|
-H 'Content-Type: application/json' \
|
|
106
115
|
-d '{"jsonrpc":"2.0","id":"3","method":"search","params":{"query":{"itemtype":"<schemaName>"},"limit":10}}' | jq
|
|
107
116
|
```
|
|
117
|
+
- fuzzySearch:
|
|
118
|
+
```bash
|
|
119
|
+
curl -s -X POST http://localhost:<PORT>/mcp \
|
|
120
|
+
-H 'Content-Type: application/json' \
|
|
121
|
+
-d '{"jsonrpc":"2.0","id":"6","method":"fuzzySearch","params":{"schema":"<schemaName>","q":"st paal","threshold":0.35,"limit":10}}' | jq
|
|
122
|
+
```
|
|
108
123
|
- search (storage):
|
|
109
124
|
```bash
|
|
110
125
|
curl -s -X POST http://localhost:<PORT>/mcp \
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"ai_global_instructions": {
|
|
3
|
+
"save_strategy": "full_object",
|
|
4
|
+
"save_steps": [
|
|
5
|
+
"Retrieve latest object with getObject",
|
|
6
|
+
"Merge updates in memory",
|
|
7
|
+
"Ensure all required fields are included",
|
|
8
|
+
"Set joeUpdated to current UTC ISO8601 (with milliseconds)",
|
|
9
|
+
"Save using saveObject"
|
|
10
|
+
],
|
|
11
|
+
"timestamps": {
|
|
12
|
+
"update_field": "joeUpdated",
|
|
13
|
+
"format": "ISO8601_with_milliseconds"
|
|
14
|
+
},
|
|
15
|
+
"search_behavior": {
|
|
16
|
+
"default_source": "cache",
|
|
17
|
+
"when_to_use_storage": "after saves or when authoritative",
|
|
18
|
+
"fallback_strategy": "list_and_filter_locally"
|
|
19
|
+
},
|
|
20
|
+
"hydration": {
|
|
21
|
+
"required": true,
|
|
22
|
+
"datasets": [
|
|
23
|
+
"status",
|
|
24
|
+
"tag",
|
|
25
|
+
"dataset",
|
|
26
|
+
"user"
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
"protection_handling": {
|
|
30
|
+
"_protected": "restrict access to assigned members or admins"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
package/server/modules/Cache.js
CHANGED
|
@@ -67,10 +67,186 @@ function Cache(updateNow) {
|
|
|
67
67
|
this.search = function(query){
|
|
68
68
|
//gets a search query and finds within all items
|
|
69
69
|
//TODO: cache queries
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
|
|
71
|
+
try{
|
|
72
|
+
if (!query) { return self.list || []; }
|
|
73
|
+
// Fuzzy search path: query contains $fuzzy
|
|
74
|
+
if (query.$fuzzy) {
|
|
75
|
+
var specs = query.$fuzzy || {};
|
|
76
|
+
var exact = {};
|
|
77
|
+
for (var k in query) { if (k !== '$fuzzy') { exact[k] = query[k]; } }
|
|
78
|
+
var candidates = (self.list || []).where(exact || {});
|
|
79
|
+
return self.fuzzySearch(candidates, specs);
|
|
80
|
+
}
|
|
81
|
+
// Default exact/where
|
|
82
|
+
return (self.list || []).where(query);
|
|
83
|
+
}catch(e){
|
|
84
|
+
console.log(JOE.Utils.color('[cache]','error')+' error in search: '+e);
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Compute fuzzy similarity across weighted fields with simple trigram Jaccard
|
|
90
|
+
this.fuzzySearch = function(items, specs){
|
|
91
|
+
try{
|
|
92
|
+
var arr = Array.isArray(items) ? items : (items ? [items] : []);
|
|
93
|
+
var q = (specs && specs.q) || '';
|
|
94
|
+
var threshold = (typeof specs.threshold === 'number') ? specs.threshold : 0.5;
|
|
95
|
+
var limit = (typeof specs.limit === 'number') ? specs.limit : 50;
|
|
96
|
+
var offset = (typeof specs.offset === 'number') ? specs.offset : 0;
|
|
97
|
+
var highlight = !!specs.highlight;
|
|
98
|
+
var minQLen = (typeof specs.minQueryLength === 'number') ? specs.minQueryLength : 2;
|
|
99
|
+
if (!q || (q+'').length < minQLen) { return []; }
|
|
100
|
+
|
|
101
|
+
var resolvedFieldsCache = {};
|
|
102
|
+
|
|
103
|
+
function norm(s){
|
|
104
|
+
try{
|
|
105
|
+
if (s == null) { return ''; }
|
|
106
|
+
var str = (typeof s === 'string') ? s : (Array.isArray(s) ? s.join(' ') : (typeof s === 'object' ? JSON.stringify(s) : (s+'')));
|
|
107
|
+
str = (str+'' ).toLowerCase();
|
|
108
|
+
// collapse and trim whitespace so blank/space-only fields don't falsely match
|
|
109
|
+
str = str.replace(/\s+/g,' ').trim();
|
|
110
|
+
try { str = str.normalize('NFD').replace(/\p{Diacritic}/gu, ''); } catch(_e) {}
|
|
111
|
+
return str;
|
|
112
|
+
}catch(e){ return (s+'' ).toLowerCase(); }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function trigrams(s){
|
|
116
|
+
var set = {};
|
|
117
|
+
for (var i=0;i<=s.length-3;i++){ set[s.substr(i,3)] = true; }
|
|
118
|
+
return set;
|
|
119
|
+
}
|
|
120
|
+
function jaccard(a,b){
|
|
121
|
+
if (!a || !b) { return 0; }
|
|
122
|
+
// Direct substring containment is strongest
|
|
123
|
+
if (a.indexOf(b) !== -1 || b.indexOf(a) !== -1) { return 1; }
|
|
124
|
+
// For very short strings, require exact if not contained
|
|
125
|
+
if (a.length < 3 || b.length < 3){ return (a === b) ? 1 : 0; }
|
|
126
|
+
var A = trigrams(a), B = trigrams(b);
|
|
127
|
+
var i=0, u=0, aSize=0;
|
|
128
|
+
var key;
|
|
129
|
+
for (key in A){ if (A.hasOwnProperty(key)) { aSize++; if (B[key]) { i++; } u++; } }
|
|
130
|
+
for (key in B){ if (B.hasOwnProperty(key) && !A[key]) { u++; } }
|
|
131
|
+
var jac = u ? (i/u) : 0;
|
|
132
|
+
var containment = aSize ? (i/aSize) : 0; // how much of query's trigrams are in field
|
|
133
|
+
return Math.max(containment, jac);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function getByPath(obj, path){
|
|
137
|
+
try{
|
|
138
|
+
if (!path) return undefined;
|
|
139
|
+
var parts = (path+'').split('.');
|
|
140
|
+
var cur = obj;
|
|
141
|
+
for (var i=0;i<parts.length;i++){
|
|
142
|
+
if (cur == null) return undefined;
|
|
143
|
+
cur = cur[parts[i]];
|
|
144
|
+
}
|
|
145
|
+
return cur;
|
|
146
|
+
}catch(e){ return undefined; }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function normalizeFieldDefs(fieldDefs, fromSchema){
|
|
150
|
+
// Accept ["name", {path:"info", weight:0.3}] or already-normalized
|
|
151
|
+
var defs = [];
|
|
152
|
+
var total = 0;
|
|
153
|
+
var i;
|
|
154
|
+
for (i=0;i<(fieldDefs||[]).length;i++){
|
|
155
|
+
var f = fieldDefs[i];
|
|
156
|
+
if (typeof f === 'string') { defs.push({ path:f, weight:0 }); }
|
|
157
|
+
else if (f && f.path) { defs.push({ path:f.path, weight: typeof f.weight==='number'? f.weight : 0 }); }
|
|
158
|
+
}
|
|
159
|
+
// If coming from schema and no explicit weights provided, assign equal weights
|
|
160
|
+
if (fromSchema === true){
|
|
161
|
+
var anyWeighted = defs.some(function(d){ return typeof d.weight === 'number' && d.weight > 0; });
|
|
162
|
+
if (!anyWeighted && defs.length){
|
|
163
|
+
var eq = 1 / defs.length;
|
|
164
|
+
return defs.map(function(d){ return { path:d.path, weight:eq }; });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// Apply default weights for known fields; distribute remaining equally across those still zero
|
|
168
|
+
var DEFAULTS = { name:0.6, info:0.3, description:0.1 };
|
|
169
|
+
for (i=0;i<defs.length;i++){
|
|
170
|
+
var key = (defs[i].path||'').split('.')[0];
|
|
171
|
+
if (defs[i].weight === 0 && DEFAULTS.hasOwnProperty(key)){
|
|
172
|
+
defs[i].weight = DEFAULTS[key];
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
total = defs.reduce(function(sum,d){ return sum + (d.weight||0); }, 0);
|
|
176
|
+
var zeros = defs.filter(function(d){ return !d.weight; });
|
|
177
|
+
var remaining = Math.max(0, 1 - total);
|
|
178
|
+
if (zeros.length && remaining > 0){
|
|
179
|
+
var each = remaining / zeros.length;
|
|
180
|
+
zeros.map(function(d){ d.weight = each; });
|
|
181
|
+
total = 1;
|
|
182
|
+
}
|
|
183
|
+
if (total === 0){
|
|
184
|
+
// No weights assigned: fall back to defaults trio
|
|
185
|
+
return [ {path:'name', weight:0.6}, {path:'info', weight:0.3}, {path:'description', weight:0.1} ];
|
|
186
|
+
}
|
|
187
|
+
// Normalize to sum=1
|
|
188
|
+
defs = defs.map(function(d){ return { path:d.path, weight: d.weight/total }; });
|
|
189
|
+
return defs;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function resolveFieldsForItem(item, explicitFields){
|
|
193
|
+
if (explicitFields && explicitFields.length){ return normalizeFieldDefs(explicitFields, false); }
|
|
194
|
+
var it = item && item.itemtype;
|
|
195
|
+
if (resolvedFieldsCache[it]) { return resolvedFieldsCache[it]; }
|
|
196
|
+
var def = (JOE && JOE.Schemas && JOE.Schemas.schema && JOE.Schemas.schema[it]) || {};
|
|
197
|
+
var fields = [];
|
|
198
|
+
if (Array.isArray(def.searchables)){
|
|
199
|
+
resolvedFieldsCache[it] = normalizeFieldDefs(def.searchables, true);
|
|
200
|
+
return resolvedFieldsCache[it];
|
|
201
|
+
}
|
|
202
|
+
// Default trio
|
|
203
|
+
resolvedFieldsCache[it] = [ {path:'name', weight:0.6}, {path:'info', weight:0.3}, {path:'description', weight:0.1} ];
|
|
204
|
+
return resolvedFieldsCache[it];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
var nq = norm(q);
|
|
208
|
+
var scored = [];
|
|
209
|
+
var useWeighted = Array.isArray(specs && specs.fields) && (specs.fields.length > 0);
|
|
210
|
+
for (var idx=0; idx<arr.length; idx++){
|
|
211
|
+
var item = arr[idx];
|
|
212
|
+
if (!item) { continue; }
|
|
213
|
+
var fields = resolveFieldsForItem(item, specs && specs.fields);
|
|
214
|
+
var score = 0;
|
|
215
|
+
var best = 0;
|
|
216
|
+
var matches = [];
|
|
217
|
+
for (var f=0; f<fields.length; f++){
|
|
218
|
+
var fd = fields[f];
|
|
219
|
+
var val = getByPath(item, fd.path);
|
|
220
|
+
var s = norm(val);
|
|
221
|
+
if (!s) { continue; }
|
|
222
|
+
var sim = jaccard(s, nq);
|
|
223
|
+
// aggregate
|
|
224
|
+
score += (fd.weight||0) * sim;
|
|
225
|
+
if (sim > best) { best = sim; }
|
|
226
|
+
if (highlight && sim > 0){
|
|
227
|
+
var ix = s.indexOf(nq);
|
|
228
|
+
if (ix !== -1){ matches.push({ field: fd.path, indices: [[ix, ix + nq.length - 1]] }); }
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
var finalScore = useWeighted ? score : best;
|
|
232
|
+
if (finalScore >= threshold){
|
|
233
|
+
var out = Object.assign({}, item);
|
|
234
|
+
out._score = +finalScore.toFixed(6);
|
|
235
|
+
if (highlight && matches.length){ out._matches = matches; }
|
|
236
|
+
scored.push(out);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
scored.sort(function(a,b){ return (b._score||0) - (a._score||0); });
|
|
240
|
+
var totalCount = scored.length;
|
|
241
|
+
if (offset && offset > 0) { scored = scored.slice(offset); }
|
|
242
|
+
if (limit && limit > 0) { scored = scored.slice(0, limit); }
|
|
243
|
+
// Attach count to a non-invasive property if array consumer needs it (not altering API here)
|
|
244
|
+
scored.count = totalCount;
|
|
245
|
+
return scored;
|
|
246
|
+
}catch(e){
|
|
247
|
+
console.log(JOE.Utils.color('[cache]','error')+' error in fuzzySearch: '+e);
|
|
248
|
+
return [];
|
|
249
|
+
}
|
|
74
250
|
}
|
|
75
251
|
this.findByID = function(collection,id,specs,returnProp){
|
|
76
252
|
try{
|
package/server/modules/MCP.js
CHANGED
|
@@ -91,7 +91,7 @@ MCP.tools = {
|
|
|
91
91
|
*/
|
|
92
92
|
|
|
93
93
|
// Unified search: defaults to cache; set source="storage" to query DB for a schema
|
|
94
|
-
search: async ({ schema, query = {}, ids, source = 'cache', limit = 50, flatten = false, depth = 1 }, _ctx) => {
|
|
94
|
+
search: async ({ schema, query = {}, ids, source = 'cache', limit = 50, flatten = false, depth = 1, countOnly = false }, _ctx) => {
|
|
95
95
|
const useCache = !source || source === 'cache';
|
|
96
96
|
const useStorage = source === 'storage';
|
|
97
97
|
|
|
@@ -113,6 +113,9 @@ MCP.tools = {
|
|
|
113
113
|
if (flatten && JOE && JOE.Utils && JOE.Utils.flattenObject) {
|
|
114
114
|
try { items = ids.map(id => JOE.Utils.flattenObject(id, { recursive: true, depth })); } catch (e) {}
|
|
115
115
|
}
|
|
116
|
+
if (countOnly) {
|
|
117
|
+
return { count: (items || []).length };
|
|
118
|
+
}
|
|
116
119
|
const sliced = (typeof limit === 'number' && limit > 0) ? items.slice(0, limit) : items;
|
|
117
120
|
return { items: sanitizeItems(sliced) };
|
|
118
121
|
}
|
|
@@ -122,6 +125,9 @@ MCP.tools = {
|
|
|
122
125
|
if (!JOE || !JOE.Cache || !JOE.Cache.search) throw new Error('Cache not initialized');
|
|
123
126
|
let results = JOE.Cache.search(query || {});
|
|
124
127
|
if (schema) results = (results || []).filter(i => i && i.itemtype === schema);
|
|
128
|
+
if (countOnly) {
|
|
129
|
+
return { count: (results || []).length };
|
|
130
|
+
}
|
|
125
131
|
const sliced = (typeof limit === 'number' && limit > 0) ? results.slice(0, limit) : results;
|
|
126
132
|
return { items: sanitizeItems(sliced) };
|
|
127
133
|
}
|
|
@@ -129,6 +135,9 @@ MCP.tools = {
|
|
|
129
135
|
if (useStorage) {
|
|
130
136
|
if (!schema) throw new Error("'schema' is required when source=storage");
|
|
131
137
|
const results = await loadFromStorage(schema, query || {});
|
|
138
|
+
if (countOnly) {
|
|
139
|
+
return { count: (results || []).length };
|
|
140
|
+
}
|
|
132
141
|
const sliced = (typeof limit === 'number' && limit > 0) ? (results || []).slice(0, limit) : (results || []);
|
|
133
142
|
if (flatten && JOE && JOE.Utils && JOE.Utils.flattenObject) {
|
|
134
143
|
try { return { items: sanitizeItems(sliced.map(it => JOE.Utils.flattenObject(it && it._id, { recursive: true, depth }))) }; } catch (e) {}
|
|
@@ -139,16 +148,35 @@ MCP.tools = {
|
|
|
139
148
|
throw new Error("Invalid 'source'. Use 'cache' (default) or 'storage'.");
|
|
140
149
|
},
|
|
141
150
|
|
|
151
|
+
// Fuzzy, typo-tolerant search over cache with weighted fields
|
|
152
|
+
fuzzySearch: async ({ schema, q, filters = {}, fields, threshold = 0.35, limit = 50, offset = 0, highlight = false, minQueryLength = 2 }, _ctx) => {
|
|
153
|
+
if (!q || (q+'').length < (minQueryLength||2)) {
|
|
154
|
+
return { items: [] };
|
|
155
|
+
}
|
|
156
|
+
if (!JOE || !JOE.Cache || !JOE.Cache.search) throw new Error('Cache not initialized');
|
|
157
|
+
var query = Object.assign({}, filters || {});
|
|
158
|
+
query.$fuzzy = { q, fields, threshold, limit, offset, highlight, minQueryLength };
|
|
159
|
+
var results = JOE.Cache.search(query) || [];
|
|
160
|
+
if (schema) { results = results.filter(function(i){ return i && i.itemtype === schema; }); }
|
|
161
|
+
var total = (typeof results.count === 'number') ? results.count : results.length;
|
|
162
|
+
if (typeof limit === 'number' && limit > 0) { results = results.slice(0, limit); }
|
|
163
|
+
return { items: sanitizeItems(results), count: total };
|
|
164
|
+
},
|
|
165
|
+
|
|
142
166
|
// Save an object via Storage (respects events/history)
|
|
143
167
|
saveObject: async ({ object }, ctx = {}) => {
|
|
144
168
|
if (!object || !object.itemtype) throw new Error("'object' with 'itemtype' is required");
|
|
145
169
|
const user = (ctx.req && ctx.req.User) ? ctx.req.User : { name: 'anonymous' };
|
|
170
|
+
// Ensure server-side update timestamp parity with /API/save
|
|
171
|
+
if (!object.joeUpdated) {
|
|
172
|
+
object.joeUpdated = new Date();
|
|
173
|
+
}
|
|
146
174
|
const saved = await new Promise((resolve, reject) => {
|
|
147
175
|
try {
|
|
148
176
|
Storage.save(object, object.itemtype, function(err, data){
|
|
149
177
|
if (err) return reject(err);
|
|
150
178
|
resolve(data);
|
|
151
|
-
}, { user });
|
|
179
|
+
}, { user, history: true });
|
|
152
180
|
} catch (e) { reject(e); }
|
|
153
181
|
});
|
|
154
182
|
return sanitizeItems(saved)[0];
|
|
@@ -189,7 +217,8 @@ MCP.descriptions = {
|
|
|
189
217
|
// getObjectsByIds: "Deprecated - use 'search' with ids.",
|
|
190
218
|
// queryObjects: "Deprecated - use 'search'.",
|
|
191
219
|
// searchCache: "Deprecated - use 'search'.",
|
|
192
|
-
search: "
|
|
220
|
+
search: "Exact search. Defaults to cache; set source=storage to query DB. Use fuzzySearch for typo-tolerant free text.",
|
|
221
|
+
fuzzySearch: "Fuzzy freeβtext search. Candidates are prefiltered by 'filters' (e.g., { itemtype: 'user' }) and then scored. If 'fields' are omitted, the schema's searchables (strings) are used with best-field scoring; provide 'fields' (strings or {path,weight}) to use weighted sums. Examples: { q: 'corey hadden', filters: { itemtype: 'user' } } or { q: 'backyard', filters: { itemtype: 'house' } }.",
|
|
193
222
|
saveObject: "Create/update an object; triggers events/history.",
|
|
194
223
|
hydrate: "Describe core fields, statuses, tags, and inferred field shapes for an optional schema."
|
|
195
224
|
};
|
|
@@ -225,10 +254,34 @@ MCP.params = {
|
|
|
225
254
|
source: { type: "string", enum: ["cache","storage"] },
|
|
226
255
|
limit: { type: "integer" },
|
|
227
256
|
flatten: { type: "boolean" },
|
|
228
|
-
depth: { type: "integer" }
|
|
257
|
+
depth: { type: "integer" },
|
|
258
|
+
countOnly: { type: "boolean" }
|
|
229
259
|
},
|
|
230
260
|
required: []
|
|
231
261
|
},
|
|
262
|
+
fuzzySearch: {
|
|
263
|
+
type: "object",
|
|
264
|
+
properties: {
|
|
265
|
+
schema: { type: "string" },
|
|
266
|
+
q: { type: "string" },
|
|
267
|
+
filters: { type: "object" },
|
|
268
|
+
fields: {
|
|
269
|
+
type: "array",
|
|
270
|
+
items: {
|
|
271
|
+
anyOf: [
|
|
272
|
+
{ type: "string" },
|
|
273
|
+
{ type: "object", properties: { path: { type: "string" }, weight: { type: "number" } }, required: ["path"] }
|
|
274
|
+
]
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
threshold: { type: "number" },
|
|
278
|
+
limit: { type: "integer" },
|
|
279
|
+
offset: { type: "integer" },
|
|
280
|
+
highlight: { type: "boolean" },
|
|
281
|
+
minQueryLength: { type: "integer" }
|
|
282
|
+
},
|
|
283
|
+
required: ["q"]
|
|
284
|
+
},
|
|
232
285
|
saveObject: {
|
|
233
286
|
type: "object",
|
|
234
287
|
properties: {
|
|
@@ -255,6 +308,14 @@ MCP.returns = {
|
|
|
255
308
|
items: { type: "array", items: { type: "object" } }
|
|
256
309
|
}
|
|
257
310
|
},
|
|
311
|
+
fuzzySearch: {
|
|
312
|
+
type: "object",
|
|
313
|
+
properties: {
|
|
314
|
+
items: { type: "array", items: { type: "object" } },
|
|
315
|
+
count: { type: "integer" }
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
// When countOnly is true, search returns { count }
|
|
258
319
|
saveObject: { type: "object" },
|
|
259
320
|
hydrate: { type: "object" }
|
|
260
321
|
};
|
package/server/modules/Server.js
CHANGED
|
@@ -228,9 +228,42 @@ server.get(['/API/user/:method'],auth,function(req,res,next){
|
|
|
228
228
|
//SEARCH
|
|
229
229
|
server.get(['/API/search/','/API/search/:query'],auth,function(req,res,next){
|
|
230
230
|
var bm = new Benchmarker();
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
231
|
+
function safeParse(q){
|
|
232
|
+
if (!q) return {};
|
|
233
|
+
if (typeof q === 'object') return q;
|
|
234
|
+
try { return JSON.parse(q); } catch(e){ return null; }
|
|
235
|
+
}
|
|
236
|
+
var raw = req.params.query || req.query.query || {};
|
|
237
|
+
var limit = parseInt(req.query.limit||0,10) || 0;
|
|
238
|
+
var queryObj = safeParse(raw);
|
|
239
|
+
if (queryObj === null){
|
|
240
|
+
return res.status(400).jsonp({ error: 'Invalid JSON in query' });
|
|
241
|
+
}
|
|
242
|
+
// Support mode=fuzzy and query params building $fuzzy
|
|
243
|
+
var mode = (req.query.mode||'').toLowerCase();
|
|
244
|
+
if (mode === 'fuzzy' && (!queryObj || !queryObj.$fuzzy)){
|
|
245
|
+
queryObj = queryObj || {};
|
|
246
|
+
var fieldsParam = req.query.fields || '';
|
|
247
|
+
var fields = [];
|
|
248
|
+
if (fieldsParam){
|
|
249
|
+
fields = fieldsParam.split(',').filter(Boolean).map(function(f){
|
|
250
|
+
var parts = f.split(':');
|
|
251
|
+
if (parts.length>1){
|
|
252
|
+
var w = parseFloat(parts[1]);
|
|
253
|
+
return { path: parts[0], weight: isNaN(w)? undefined : w };
|
|
254
|
+
}
|
|
255
|
+
return parts[0];
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
queryObj.$fuzzy = {
|
|
259
|
+
q: req.query.q || '',
|
|
260
|
+
fields: fields.length? fields : undefined,
|
|
261
|
+
threshold: (typeof req.query.threshold !== 'undefined')? parseFloat(req.query.threshold) : undefined,
|
|
262
|
+
limit: limit || undefined,
|
|
263
|
+
offset: (typeof req.query.offset !== 'undefined')? parseInt(req.query.offset,10) : undefined,
|
|
264
|
+
highlight: (req.query.highlight==='true'||req.query.highlight==='1') || undefined
|
|
265
|
+
};
|
|
266
|
+
}
|
|
234
267
|
var payload = {query:queryObj};
|
|
235
268
|
payload.results = JOE.Cache.search(queryObj);
|
|
236
269
|
/*payload.results.map(function(res){
|
|
@@ -242,9 +275,7 @@ server.get(['/API/search/','/API/search/:query'],auth,function(req,res,next){
|
|
|
242
275
|
})*/
|
|
243
276
|
|
|
244
277
|
payload.count = payload.results.length;
|
|
245
|
-
if(
|
|
246
|
-
payload.results = payload.results.slice(0,limit)
|
|
247
|
-
}
|
|
278
|
+
if(limit){ payload.results = payload.results.slice(0,limit); }
|
|
248
279
|
payload.results = cleanPayload(payload.results);
|
|
249
280
|
payload.benchmark = bm.stop();
|
|
250
281
|
res.jsonp(payload);
|
|
@@ -5,7 +5,7 @@ var instance = function(){
|
|
|
5
5
|
info:"Instances can be used to make a shallow clone (to be modified of a particular) ",
|
|
6
6
|
listWindowTitle: 'Instances',
|
|
7
7
|
default_schema:true,
|
|
8
|
-
|
|
8
|
+
searchables:['name','info','description','_id'],
|
|
9
9
|
menuicon:'<svg xmlns="http://www.w3.org/2000/svg" viewBox="-16 -16 64 64"><path d="M4 11V6c0-1.1 0.9-2 2-2h4V0H6C2.7 0 0 2.7 0 6v5H4z"/><path d="M22 4h4c1.1 0 2 0.9 2 2v5h4V6c0-3.3-2.7-6-6-6h-4V4z"/><path d="M10 28H6c-1.1 0-2-0.9-2-2v-3H0v3C0 29.3 2.7 32 6 32h4V28z"/><path d="M28 23v3c0 1.1-0.9 2-2 2h-4V32h4c3.3 0 6-2.7 6-6v-3H28z"/></svg>',
|
|
10
10
|
listTitle:function(instance){
|
|
11
11
|
var icon = '';
|
package/server/schemas/task.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
var task = function(){return{
|
|
2
2
|
title : '${name}',
|
|
3
3
|
info:"Create a new task and assign it to a team member in the project panel.",
|
|
4
|
+
ai_instructions:"",
|
|
4
5
|
listWindowTitle: 'Tasks',
|
|
5
6
|
gridView:{
|
|
6
7
|
cols:[
|
|
@@ -44,7 +45,7 @@ var task = function(){return{
|
|
|
44
45
|
+`<joe-title>${item.name}</joe-title><joe-subtitle>${item.info}</joe-subtitle>
|
|
45
46
|
${item.due_date && `<joe-subtext>due ${item.due_date}</joe-subtext>`||''}`
|
|
46
47
|
},
|
|
47
|
-
|
|
48
|
+
searchables:['name','info','description','_id'],
|
|
48
49
|
sorter:[
|
|
49
50
|
'priority','project','status',
|
|
50
51
|
{field:'!due_date',display:'due'},
|
package/server/schemas/user.js
CHANGED
|
@@ -2,7 +2,7 @@ var user = function(){return{
|
|
|
2
2
|
title : '${name}',
|
|
3
3
|
info:"Manage each user you've given access to supervise, edit, or view elements of your dashboard.",
|
|
4
4
|
default_schema:true,
|
|
5
|
-
|
|
5
|
+
searchables:['name','info','fullname','_id','email'],
|
|
6
6
|
listView:{
|
|
7
7
|
|
|
8
8
|
title: function(user){
|