aboutsummaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
Diffstat (limited to 'modules')
-rw-r--r--modules/core/core-bundle.js83
-rw-r--r--modules/core/module-aiassist.js83
-rw-r--r--modules/core/module-loader.js2
3 files changed, 153 insertions, 15 deletions
diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js
index e265b68..48d9dbe 100644
--- a/modules/core/core-bundle.js
+++ b/modules/core/core-bundle.js
@@ -11015,24 +11015,71 @@ buildSystemPrompt() {
return JSON.stringify({ message: `Agent spawned: ${agent.id}`, agent });
}
case 'load_skills': {
- const query = (a && a.query || '').toLowerCase();
+ // v4.6.62: tokenised, body-aware search. The old matcher tested the
+ // WHOLE query as one substring against name+description+tags, so word
+ // order decided the outcome ("telemetry dashboard" = 0 while
+ // "telemetry" = 1) and a sentence could never match anything. Skill
+ // bodies were never searched at all (packet §4).
+ const rawQuery = (a && a.query) || '';
+ const query = String(rawQuery).toLowerCase().trim();
const fs = this.terminal.fs;
const index = fs.fsReadJSON('/skills/index.json') || {};
if (!query) {
const names = Object.keys(index);
return JSON.stringify({ skills: names, count: names.length, message: 'No query provided — showing all skill names. Call again with a query to load content.' });
}
+ const tokens = this._skillTokens(query);
const matches = [];
for (const [name, meta] of Object.entries(index)) {
- const score = (name.includes(query) ? 3 : 0) + (meta.description.toLowerCase().includes(query) ? 2 : 0) + ((meta.tags || []).some(t => t.toLowerCase().includes(query)) ? 2 : 0);
- if (score > 0) {
- const content = fs.readFile(`/skills/${name}.md`) || '';
- matches.push({ name, description: meta.description, category: meta.category, score, content: content.substring(0, 2000) });
+ const nameL = name.toLowerCase();
+ const descL = String(meta.description || '').toLowerCase();
+ const tagsL = (meta.tags || []).map(x => String(x).toLowerCase());
+ const bodyRaw = fs.readFile('/skills/' + name + '.md') || '';
+ const bodyL = bodyRaw.toLowerCase();
+ let score = 0;
+ let matched = 0;
+ for (const tok of tokens) {
+ let hit = 0;
+ if (nameL.includes(tok)) hit += 3;
+ if (descL.includes(tok)) hit += 2;
+ if (tagsL.some(x => x.includes(tok))) hit += 2;
+ if (bodyL) {
+ const n = bodyL.split(tok).length - 1;
+ if (n > 0) hit += 1 + Math.min(n, 4) * 0.25;
+ }
+ if (hit > 0) { score += hit; matched++; }
}
+ if (!matched) continue;
+ // phrase bonus: word order must never be able to produce a zero
+ if ((nameL + ' ' + descL).includes(query)) score += 3;
+ // coverage: matching 1 of 4 tokens is a weak hit, not a strong one
+ score = score * (0.5 + 0.5 * (matched / tokens.length));
+ matches.push({
+ name: name,
+ description: meta.description,
+ category: meta.category,
+ score: Math.round(score * 100) / 100,
+ matchedTokens: matched + '/' + tokens.length,
+ content: bodyRaw.substring(0, 2000)
+ });
}
- matches.sort((a, b) => b.score - a.score);
+ matches.sort((x, y) => y.score - x.score || (x.name < y.name ? -1 : 1));
const top = matches.slice(0, 3);
- return JSON.stringify(top.length > 0 ? { matches: top.map(m => ({ name: m.name, description: m.description, content: m.content })), count: top.length } : { matches: [], count: 0, message: 'No matching skills found. You can create one after this task.' });
+ if (!top.length) {
+ const names = Object.keys(index);
+ return JSON.stringify({
+ matches: [], count: 0, tokens: tokens,
+ message: 'No skill matched any of: ' + tokens.join(', ') +
+ '. Closest skill names: ' + names.slice(0, 8).join(', ') +
+ '. You can create one after this task.'
+ });
+ }
+ return JSON.stringify({
+ matches: top.map(m => ({ name: m.name, description: m.description,
+ content: m.content, score: m.score, matchedTokens: m.matchedTokens })),
+ count: top.length,
+ tokens: tokens
+ });
}
case 'create_skill': {
const name = (a.name || '').trim().replace(/\s+/g, '-').toLowerCase();
@@ -11403,6 +11450,28 @@ buildSystemPrompt() {
return JSON.stringify({ error: e.message, stack: e.stack?.substring(0, 200) });
}
}
+ // v4.6.62: query tokeniser for load_skills — lowercase, split on non-word chars,
+ // drop short words and stopwords. A sentence query keeps its content tokens instead
+ // of collapsing to a phrase that can never match.
+ _skillTokens(query) {
+ const STOP = new Set(['the','and','for','with','how','what','when','which','who','why',
+ 'does','did','can','could','should','would','you','your','our','their','this','that',
+ 'these','those','from','into','about','over','under','use','using','used','get','got',
+ 'make','made','need','want','please','help','me','my','we','it','is','are','was','were',
+ 'be','been','to','of','in','on','at','by','or','not','no','do','i','a','an','as','if']);
+ const seen = new Set();
+ const out = [];
+ String(query || '').toLowerCase().split(/[^a-z0-9_\-]+/).forEach(w => {
+ if (!w || w.length < 3 || STOP.has(w) || seen.has(w)) return;
+ seen.add(w); out.push(w);
+ });
+ // A query made entirely of stopwords still deserves a literal attempt.
+ if (!out.length) {
+ const literal = String(query || '').trim();
+ if (literal) out.push(literal);
+ }
+ return out;
+ }
async runModuleTool(name, argsStr) {
let a = {};
try {
diff --git a/modules/core/module-aiassist.js b/modules/core/module-aiassist.js
index 3326483..f5895f3 100644
--- a/modules/core/module-aiassist.js
+++ b/modules/core/module-aiassist.js
@@ -2698,24 +2698,71 @@ buildSystemPrompt() {
return JSON.stringify({ message: `Agent spawned: ${agent.id}`, agent });
}
case 'load_skills': {
- const query = (a && a.query || '').toLowerCase();
+ // v4.6.62: tokenised, body-aware search. The old matcher tested the
+ // WHOLE query as one substring against name+description+tags, so word
+ // order decided the outcome ("telemetry dashboard" = 0 while
+ // "telemetry" = 1) and a sentence could never match anything. Skill
+ // bodies were never searched at all (packet §4).
+ const rawQuery = (a && a.query) || '';
+ const query = String(rawQuery).toLowerCase().trim();
const fs = this.terminal.fs;
const index = fs.fsReadJSON('/skills/index.json') || {};
if (!query) {
const names = Object.keys(index);
return JSON.stringify({ skills: names, count: names.length, message: 'No query provided — showing all skill names. Call again with a query to load content.' });
}
+ const tokens = this._skillTokens(query);
const matches = [];
for (const [name, meta] of Object.entries(index)) {
- const score = (name.includes(query) ? 3 : 0) + (meta.description.toLowerCase().includes(query) ? 2 : 0) + ((meta.tags || []).some(t => t.toLowerCase().includes(query)) ? 2 : 0);
- if (score > 0) {
- const content = fs.readFile(`/skills/${name}.md`) || '';
- matches.push({ name, description: meta.description, category: meta.category, score, content: content.substring(0, 2000) });
+ const nameL = name.toLowerCase();
+ const descL = String(meta.description || '').toLowerCase();
+ const tagsL = (meta.tags || []).map(x => String(x).toLowerCase());
+ const bodyRaw = fs.readFile('/skills/' + name + '.md') || '';
+ const bodyL = bodyRaw.toLowerCase();
+ let score = 0;
+ let matched = 0;
+ for (const tok of tokens) {
+ let hit = 0;
+ if (nameL.includes(tok)) hit += 3;
+ if (descL.includes(tok)) hit += 2;
+ if (tagsL.some(x => x.includes(tok))) hit += 2;
+ if (bodyL) {
+ const n = bodyL.split(tok).length - 1;
+ if (n > 0) hit += 1 + Math.min(n, 4) * 0.25;
+ }
+ if (hit > 0) { score += hit; matched++; }
}
+ if (!matched) continue;
+ // phrase bonus: word order must never be able to produce a zero
+ if ((nameL + ' ' + descL).includes(query)) score += 3;
+ // coverage: matching 1 of 4 tokens is a weak hit, not a strong one
+ score = score * (0.5 + 0.5 * (matched / tokens.length));
+ matches.push({
+ name: name,
+ description: meta.description,
+ category: meta.category,
+ score: Math.round(score * 100) / 100,
+ matchedTokens: matched + '/' + tokens.length,
+ content: bodyRaw.substring(0, 2000)
+ });
}
- matches.sort((a, b) => b.score - a.score);
+ matches.sort((x, y) => y.score - x.score || (x.name < y.name ? -1 : 1));
const top = matches.slice(0, 3);
- return JSON.stringify(top.length > 0 ? { matches: top.map(m => ({ name: m.name, description: m.description, content: m.content })), count: top.length } : { matches: [], count: 0, message: 'No matching skills found. You can create one after this task.' });
+ if (!top.length) {
+ const names = Object.keys(index);
+ return JSON.stringify({
+ matches: [], count: 0, tokens: tokens,
+ message: 'No skill matched any of: ' + tokens.join(', ') +
+ '. Closest skill names: ' + names.slice(0, 8).join(', ') +
+ '. You can create one after this task.'
+ });
+ }
+ return JSON.stringify({
+ matches: top.map(m => ({ name: m.name, description: m.description,
+ content: m.content, score: m.score, matchedTokens: m.matchedTokens })),
+ count: top.length,
+ tokens: tokens
+ });
}
case 'create_skill': {
const name = (a.name || '').trim().replace(/\s+/g, '-').toLowerCase();
@@ -3086,6 +3133,28 @@ buildSystemPrompt() {
return JSON.stringify({ error: e.message, stack: e.stack?.substring(0, 200) });
}
}
+ // v4.6.62: query tokeniser for load_skills — lowercase, split on non-word chars,
+ // drop short words and stopwords. A sentence query keeps its content tokens instead
+ // of collapsing to a phrase that can never match.
+ _skillTokens(query) {
+ const STOP = new Set(['the','and','for','with','how','what','when','which','who','why',
+ 'does','did','can','could','should','would','you','your','our','their','this','that',
+ 'these','those','from','into','about','over','under','use','using','used','get','got',
+ 'make','made','need','want','please','help','me','my','we','it','is','are','was','were',
+ 'be','been','to','of','in','on','at','by','or','not','no','do','i','a','an','as','if']);
+ const seen = new Set();
+ const out = [];
+ String(query || '').toLowerCase().split(/[^a-z0-9_\-]+/).forEach(w => {
+ if (!w || w.length < 3 || STOP.has(w) || seen.has(w)) return;
+ seen.add(w); out.push(w);
+ });
+ // A query made entirely of stopwords still deserves a literal attempt.
+ if (!out.length) {
+ const literal = String(query || '').trim();
+ if (literal) out.push(literal);
+ }
+ return out;
+ }
async runModuleTool(name, argsStr) {
let a = {};
try {
diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js
index ddb2874..2871ef3 100644
--- a/modules/core/module-loader.js
+++ b/modules/core/module-loader.js
@@ -4,7 +4,7 @@
// Core module bundle loaded synchronously in main.html.
// This loader only handles version cache-busting, splash, terminal init, and optional ext modules.
- const LATEST_VER = 'v4.6.61';
+ const LATEST_VER = 'v4.6.62';
const CACHE = '?v=' + LATEST_VER;
const stored = localStorage.getItem('webx11_cache_ver');
if (stored !== LATEST_VER) {