feat: Add Be and tbd skill, also added Roadmap file

This commit is contained in:
2026-05-10 16:32:12 -04:00
parent 3500ade13f
commit 0bb8885802
29587 changed files with 10611695 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
# Menu providers (drop-in menu customization)
Providers let you add/override menu entries without editing the core menu patch.
Default entries directory (on robot):
- `/opt/jibo/Jibo/Skills/@be/menu-entries.d/`
Legacy fallback (yea ik its the same stop talking):
- `/opt/jibo/Jibo/Skills/@be/menu-providers.d/`
## Provider file types
### JSON provider (`*.json`)
Supported shapes:
- An array of entries: `[ { ... }, { ... } ]`
- Or `{ "entries": [ ... ] }`
Entry schema (same as `menuEntry.json` scan output):
- `id` (string, required)
- `type` (`skill` or `submenu`)
- `title`
- `icon`
- `color`
- `description`
- `order` (number)
- `skillId` (for type `skill`)
- `submenuTitle` (for type `submenu`)
- `children` (array of skill entries, for type `submenu`)
- `childrenDir` (for type `submenu`, optional):
- Absolute path (starts with `/`) or relative to `skillsRoot`
- If provided and `children` is missing/empty, the patch will scan this directory for child skills (subfolders containing `menuEntry.json`).
Example submenu that lists a directory:
```json
[
{
"id": "fun_stuff",
"type": "submenu",
"title": "Fun Stuff",
"icon": "resources/icons/fun-stuff.png",
"order": 20,
"childrenDir": "FunStuff"
}
]
```
### JS provider (`*.js`)
Exports one of:
- `module.exports = function(ctx) { return [ ...entries... ]; }`
- `exports.getEntries = function(ctx) { return [ ...entries... ]; }`
- `exports.entries = [ ...entries... ]`
`ctx` includes:
- `skillsRoot`
- `providersDir`
- `log` (function)
## Conflict rules
- If a provider entry has the same `id` as a scanned entry, the provider entry wins.
- Sorting: by `order` then by title.

View File

@@ -0,0 +1,225 @@
# Jibo Dynamic Menu System
This module provides a dynamic menu system that automatically scans the Skills directory for `menuEntry.json` files and adds them to Jibo's menu screen.
## How It Works
When Jibo boots up, the `main-menu-patch.js` module:
1. Patches `jibo.loader.load()` to intercept menu loading
2. Scans the `/opt/jibo/Jibo/Skills` directory for folders containing `menuEntry.json`
3. Injects the found skills/submenus into the main menu
4. Patches the main-menu skill's `redirectToSkill` to handle custom skill launches
## Quick Start
### Adding a Simple Skill Button to the Menu (you really should follow PROVIDERS.md at this point)
1. Create a folder in the `Skills` directory (e.g., `MySkill/`)
2. Add a `menuEntry.json` file inside:
```json
{
"type": "skill",
"title": "My Skill",
"icon": "resources/icons/settings.png",
"color": "blue",
"skillId": "MySkill",
"description": "Short description of my skill",
"order": 50
}
```
3. Your skill will automatically appear in Jibo's main menu!
**Note:** For the button to actually launch a skill, that skill must be a proper Jibo skill package registered in `@be/be/package.json`. If the skill isn't found, Jibo will show a fallback message.
### Creating a Submenu (Category/Folder)
1. Create a folder in the `Skills` directory (e.g., `Games/`)
2. Add a `menuEntry.json` marking it as a submenu:
```json
{
"type": "submenu",
"title": "Games",
"submenuTitle": "Choose a Game",
"icon": "resources/icons/fun-stuff.png",
"color": "purple",
"description": "Fun games to play with Jibo",
"order": 10
}
```
3. Inside that folder, create subfolders for each skill, each with their own `menuEntry.json`
4. A button will appear in the main menu that opens a submenu with those skills!
## Directory Structure Example (OLD)
```
/opt/jibo/Jibo/Skills/
├── @be/ # Core brain (ignored by scanner)
├── MySimpleSkill/
│ └── menuEntry.json # type: "skill" - appears as button in main menu
├── MyGames/
│ ├── menuEntry.json # type: "submenu" - opens submenu when pressed
│ ├── GameOne/
│ │ └── menuEntry.json # type: "skill" - appears in MyGames submenu
│ └── GameTwo/
│ └── menuEntry.json # type: "skill" - appears in MyGames submenu
```
## menuEntry.json Schema
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | `"skill"` \| `"submenu"` | **Yes** | `"skill"` for buttons, `"submenu"` for folders |
| `title` | string | **Yes** | Button label shown on Jibo's screen |
| `icon` | string | **Yes** | Icon path (see Icon Paths below) |
| `color` | string | No | Button color theme (see below). Default: `"default"` |
| `skillId` | string | **Yes** (for skills) | The skill ID to launch |
| `submenuTitle` | string | No | Title shown when submenu is opened |
| `description` | string | No | Tooltip/accessibility text |
| `order` | number | No | Sort order (lower = first). Default: `100` |
| `hidden` | boolean | No | Set to `true` to hide from menu |
### Available Colors
- `default` - Standard gray
- `blue` - Blue theme
- `green` - Green theme
- `red` - Red theme
- `purple` - Purple theme
- `orange` - Orange theme
- `teal` - Teal/cyan theme
- `pink` - Pink theme
- `yellow` - Yellow theme
- `cyan` - Cyan theme
### Icon Paths
Icons are resolved relative to the `@be/main-menu` package. Available built-in icons:
| Icon Path | Description |
|-----------|-------------|
| `resources/icons/settings.png` | Settings gear |
| `resources/icons/fun-stuff.png` | Star/fun icon (good for submenus) |
| `resources/icons/clock.png` | Clock |
| `resources/icons/heart.png` | Heart |
| `resources/icons/surprise.png` | Gift/surprise |
| `resources/icons/dance.png` | Dancing figure |
| `resources/icons/joke.png` | Comedy mask |
| `resources/icons/tips.png` | Lightbulb |
| `resources/icons/gallery.png` | Photo gallery |
| `resources/icons/radio.png` | Music notes |
| `resources/icons/create.png` | Camera |
| `resources/icons/photobooth.png` | Photo booth |
| `resources/icons/exercise.png` | Exercise/yoga |
| `resources/icons/weather.png` | Weather cloud |
| `resources/icons/calendar.png` | Calendar |
| `resources/icons/circuit-saver.png` | Circuit saver |
| `resources/icons/dice.png` | Dice |
| `resources/icons/coin.png` | Coin |
| `resources/icons/emoji.png` | Emoji face |
| `resources/icons/word-of-the-day.png` | Word icon |
| `resources/icons/news.png` | News icon |
| `resources/icons/twerk.png` | Twerk icon |
| `resources/icons/scanner.png` | Scanner icon |
## Example: Complete Skill Setup
### Simple Skill (testSkillA/menuEntry.json)
```json
{
"type": "skill",
"title": "Test Skill A",
"icon": "resources/icons/settings.png",
"color": "blue",
"skillId": "testSkillA",
"description": "A test skill",
"order": 10
}
```
### Submenu (TestSubMenuA/menuEntry.json)
```json
{
"type": "submenu",
"title": "Test Submenu",
"submenuTitle": "Test Skills",
"icon": "resources/icons/fun-stuff.png",
"color": "purple",
"description": "A submenu with test skills",
"order": 20
}
```
### Skill inside Submenu (TestSubMenuA/testSkillB/menuEntry.json)
```json
{
"type": "skill",
"title": "Test Skill B",
"icon": "resources/icons/surprise.png",
"color": "green",
"skillId": "testSkillB",
"description": "A skill inside a submenu",
"order": 1
}
```
## Technical Details
### How the Patch Works
1. **postinit.js** calls `mainMenuPatch.applyPatch(be)` and `mainMenuPatch.patchLoader(jibo)`
2. When the main-menu skill tries to load `main-menu-verbal.json`, the patch intercepts it
3. The patch scans the Skills directory for `menuEntry.json` files
4. It converts them to the proper button format and injects them into the menu config
5. When a dynamic skill button is pressed, the patched `redirectToSkill` handles the launch
6. For submenus, a new menu view is displayed with the submenu's children
### Files
- `main-menu-patch.js` - Core patch that hooks into the system
- `skills-scanner.js` - Alternative scanning implementation (not currently used)
- `menu-manager.js` - Alternative menu display implementation (not currently used)
### Debugging
Check the browser console for log messages starting with `main-menu-patch:`. These show:
- When the patch is initialized
- When menu loads are intercepted
- How many dynamic skills are injected
- When skills/submenus are selected
## Limitations
- Nested submenus (submenus within submenus) are not supported
- Custom skills must be registered in `@be/be/package.json` to actually launch
- Icon files must exist in the `@be/main-menu/resources/icons/` directory
## Troubleshooting
### Skill button not appearing in menu?
1. Check that `menuEntry.json` exists and is valid JSON
2. Verify `hidden` is not set to `true`
3. Make sure `type` is set to `"skill"` or `"submenu"`
4. Check the browser console for `main-menu-patch:` log messages
### Submenu appears empty?
1. Make sure child skill folders have `menuEntry.json` files
2. Verify child skills have `type: "skill"` (nested submenus not supported)
3. Check that child folders are direct children of the submenu folder
### Skill button doesn't launch anything?
1. The skill needs to be a proper Jibo skill package
2. The skill must be registered in `@be/be/package.json` under `jibo.skills`
3. If unregistered, Jibo will show a fallback message
### Icon not loading?
1. Verify the icon path is correct (e.g., `resources/icons/settings.png`)
2. Only built-in icons from `@be/main-menu/resources/icons/` are supported
3. Check the list of available icons above

View File

@@ -0,0 +1,675 @@
"use strict";
/**
* Main Menu Patch - Injects dynamic skills from menuEntry.json files into Jibo's menu
*
* This patches:
* 1. jibo.loader.load() to intercept menu JSON loading and inject dynamic skills
* 2. The main-menu skill's redirectToSkill method to handle custom skill IDs
*/
const fs = require('fs');
const path = require('path');
const menuEntries = require('./menu-entries');
const skillsRootUtil = require('./skills-root');
function strStartsWith(s, prefix) {
return (typeof s === 'string') && (s.indexOf(prefix) === 0);
}
function strEndsWith(s, suffix) {
if (typeof s !== 'string' || typeof suffix !== 'string') return false;
if (suffix.length > s.length) return false;
return s.indexOf(suffix, s.length - suffix.length) !== -1;
}
function strIncludes(s, needle) {
return (typeof s === 'string') && (s.indexOf(needle) !== -1);
}
// Optional UDP logger (Python logd). Safe fallback if unavailable.
let robotLogger = null;
try {
robotLogger = require('../be/robot-logger');
} catch (e) {
robotLogger = null;
}
// Skills root directory (configurable)
let SKILLS_ROOT = skillsRootUtil.resolveSkillsRoot();
// Cache for scanned skills
let _cachedMenuEntries = null;
let _cachedSkillsRoot = null;
let _cachedProvidersDir = null;
let _submenuConfigs = {};
let _dynamicSkillIds = new Set(); // Track dynamic skill IDs for routing
let _beInstance = null;
let _jibo = null;
// Color mappings for skill buttons (name -> hex array)
const COLOR_MAP = {
'default': ['0xBBC6CA', '0x434D55'],
'blue': ['0x3765AB', '0x1A2563'],
'green': ['0x8EDD40', '0x31732A'],
'red': ['0xE81853', '0x850F40'],
'purple': ['0x9B59B6', '0x6C3483'],
'orange': ['0xFF892F', '0xAF4123'],
'teal': ['0x2BEFDC', '0x086969'],
'pink': ['0xFF69B4', '0xC71585'],
'cyan': ['0x25F2FB', '0x107799'],
'yellow': ['0xFFD700', '0xB8860B']
};
// Logging helper - writes to both console and file
const LOG_FILE = '/tmp/menu-patch.log';
function log(msg, ...args) {
const line = '[MENU-PATCH] ' + msg + ' ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ');
console.log(line);
try {
if (robotLogger && typeof robotLogger.raw === 'function') {
robotLogger.raw(line);
}
} catch (e) { }
try {
fs.appendFileSync(LOG_FILE, new Date().toISOString() + ' ' + line + '\n');
} catch (e) {}
}
function warn(msg, ...args) {
const line = '[MENU-PATCH WARN] ' + msg + ' ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ');
console.warn(line);
try {
if (robotLogger && typeof robotLogger.raw === 'function') {
robotLogger.raw(line);
}
} catch (e) { }
try {
fs.appendFileSync(LOG_FILE, new Date().toISOString() + ' ' + line + '\n');
} catch (e) {}
}
/**
* Safely read and parse a JSON file
*/
function safeReadJson(filePath) {
try {
const txt = fs.readFileSync(filePath, 'utf8');
if (!txt || txt.trim().length === 0) return null;
return JSON.parse(txt);
} catch (e) {
log('safeReadJson error for', filePath, e.message);
return null;
}
}
/**
* Check if path is a directory
*/
function isDirectory(p) {
try {
return fs.statSync(p).isDirectory();
} catch (e) {
return false;
}
}
/**
* Get colors array from color name or return default
*/
function getColors(colorName) {
if (Array.isArray(colorName)) return colorName;
if (typeof colorName === 'string' && colorName.startsWith('0x')) {
return [colorName, colorName];
}
return COLOR_MAP[colorName] || COLOR_MAP['default'];
}
/**
* Scan a skill directory for menuEntry.json
*/
function scanSkillEntry(skillDir, folderName) {
const menuJsonPath = path.join(skillDir, 'menuEntry.json');
const menuJson = safeReadJson(menuJsonPath);
if (!menuJson) {
return null;
}
if (menuJson.hidden === true) {
log('Skipping hidden entry:', folderName);
return null;
}
log('Found menuEntry.json in', folderName, '- type:', menuJson.type);
const entry = {
id: folderName,
type: menuJson.type || 'skill',
title: menuJson.title || menuJson.label || folderName,
icon: menuJson.icon || menuJson.iconSrc || 'resources/icons/settings.png',
color: menuJson.color || menuJson.colors || 'default',
description: menuJson.description || '',
path: skillDir,
order: typeof menuJson.order === 'number' ? menuJson.order : 100,
skillId: menuJson.skillId || folderName,
submenuTitle: menuJson.submenuTitle || menuJson.title || folderName
};
// Track this as a dynamic skill
if (entry.type === 'skill') {
_dynamicSkillIds.add(entry.skillId);
_dynamicSkillIds.add(entry.id);
}
// For submenus, scan child directories
if (entry.type === 'submenu') {
entry.children = scanSubmenuChildren(skillDir);
log('Submenu', folderName, 'has', entry.children.length, 'children');
}
return entry;
}
/**
* Scan children of a submenu directory
*/
function scanSubmenuChildren(submenuDir) {
const children = [];
let items;
try {
items = fs.readdirSync(submenuDir);
} catch (e) {
warn('Failed to read submenu dir:', submenuDir, e.message);
return children;
}
items.forEach(name => {
if (name.startsWith('.') || name === '@be' || name === 'node_modules') return;
const childPath = path.join(submenuDir, name);
if (!isDirectory(childPath)) return;
const entry = scanSkillEntry(childPath, name);
if (entry && entry.type === 'skill') {
children.push(entry);
}
});
// Sort by order, then alphabetically
children.sort((a, b) => {
if (a.order !== b.order) return a.order - b.order;
return a.title.localeCompare(b.title);
});
return children;
}
/**
* Scan the Skills root directory for all menu entries
*/
function scanAllMenuEntries() {
if (_cachedMenuEntries && _cachedSkillsRoot === SKILLS_ROOT) {
log('Returning cached entries:', _cachedMenuEntries.length);
return _cachedMenuEntries;
}
log('Scanning Skills directory:', SKILLS_ROOT);
_dynamicSkillIds.clear();
const res = menuEntries.getMenuEntries({
skillsRoot: SKILLS_ROOT,
log: function () {
try { log.apply(null, arguments); } catch (e) { }
}
});
_cachedMenuEntries = res.entries || [];
_cachedSkillsRoot = res.skillsRoot;
_cachedProvidersDir = res.providersDir;
// update dynamic id set
try {
if (res.dynamicSkillIds && typeof res.dynamicSkillIds.forEach === 'function') {
res.dynamicSkillIds.forEach(function (id) { _dynamicSkillIds.add(id); });
}
} catch (e) { }
log('Total entries found:', _cachedMenuEntries.length);
if (_cachedProvidersDir) {
log('Providers dir:', _cachedProvidersDir);
}
return _cachedMenuEntries;
}
/**
* Convert a menu entry to the main-menu button format
*/
function entryToMenuButton(entry) {
const button = {
id: entry.id,
label: entry.title,
colors: getColors(entry.color),
iconSrc: entry.icon
};
if (entry.type === 'submenu') {
// For submenus, create an action that loads a dynamically generated submenu
const submenuId = 'dynamic-submenu-' + entry.id;
// Register the submenu configuration
_submenuConfigs[submenuId] = createSubmenuConfig(entry);
button.action = {
type: 'utterance',
data: {
utterance: {
intent: 'loadMenu',
entities: {
destination: submenuId
}
}
}
};
} else {
// Regular skill button - use utterance format with dynamic marker
button.action = {
type: 'utterance',
data: {
utterance: {
intent: 'loadMenu',
entities: {
destination: 'dynamic:' + entry.skillId
}
}
}
};
}
return button;
}
/**
* Create a submenu view configuration
*/
function createSubmenuConfig(entry) {
const config = {
rule: 'main-menu/execute_main_menu',
timeout: 8,
viewConfig: {
type: 'MenuView',
id: 'submenu-' + entry.id,
title: entry.submenuTitle || entry.title,
listDefault: {
menuButtonType: 'SkillButton'
},
list: []
}
};
// Add back button that closes the menu (returns to main)
config.viewConfig.list.push({
id: '__back__',
label: '← Back',
colors: COLOR_MAP['default'],
iconSrc: 'resources/icons/settings.png',
action: {
type: 'utterance',
data: {
utterance: {
intent: 'loadMenu',
entities: {
destination: '__goback__'
}
}
}
}
});
// Add skill buttons from children
if (entry.children && entry.children.length > 0) {
entry.children.forEach(child => {
config.viewConfig.list.push({
id: child.id,
label: child.title,
colors: getColors(child.color),
iconSrc: child.icon,
action: {
type: 'utterance',
data: {
utterance: {
intent: 'loadMenu',
entities: {
destination: 'dynamic:' + child.skillId
}
}
}
}
});
});
}
return config;
}
/**
* Inject dynamic skills into a loaded menu config
*/
function injectDynamicSkills(originalConfig) {
log('injectDynamicSkills called');
if (!originalConfig) {
warn('originalConfig is null/undefined');
return originalConfig;
}
if (!originalConfig.viewConfig) {
warn('originalConfig.viewConfig is missing');
return originalConfig;
}
if (!originalConfig.viewConfig.list) {
warn('originalConfig.viewConfig.list is missing');
return originalConfig;
}
log('Original config has', originalConfig.viewConfig.list.length, 'buttons');
const entries = scanAllMenuEntries();
if (entries.length === 0) {
warn('No dynamic entries found to inject');
return originalConfig;
}
// Clone the config to avoid mutating the original
const config = JSON.parse(JSON.stringify(originalConfig));
// Add dynamic skill buttons to the list
entries.forEach(entry => {
const button = entryToMenuButton(entry);
log('Adding button:', button.label, '- id:', button.id);
config.viewConfig.list.push(button);
});
log('Final config has', config.viewConfig.list.length, 'buttons');
log('Injected', entries.length, 'dynamic skills into menu');
return config;
}
/**
* Patch the main-menu skill's redirectToSkill method
*/
function patchMainMenuSkill(be) {
log('patchMainMenuSkill called');
if (!be) {
warn('be is null/undefined');
return;
}
if (!be.skills) {
warn('be.skills is null/undefined');
return;
}
if (!be.skills['@be/main-menu']) {
warn('@be/main-menu skill not found in be.skills');
log('Available skills:', Object.keys(be.skills).join(', '));
return;
}
const mainMenuSkill = be.skills['@be/main-menu'];
const originalRedirectToSkill = mainMenuSkill.redirectToSkill.bind(mainMenuSkill);
mainMenuSkill.redirectToSkill = function(skill) {
log('redirectToSkill called with:', skill);
// Handle back navigation
if (skill === '__goback__') {
log('Going back to main menu');
if (_jibo && _jibo.face && _jibo.face.views) {
_jibo.face.views.changeView({
remove: true,
leaveEmpty: true,
transitionClose: _jibo.face.views.TRANSITION.UP
}, () => {
// Reload the main menu by launching main-menu skill again
this.redirect('@be/main-menu', {});
});
}
return;
}
// Handle dynamic submenus
if (skill && strStartsWith(skill, 'dynamic-submenu-')) {
log('Showing submenu:', skill);
const submenuConfig = _submenuConfigs[skill];
if (submenuConfig && _jibo && _jibo.face && _jibo.face.views) {
// Show the submenu view
_jibo.face.views.changeView(submenuConfig, (err, result) => {
if (err) {
warn('Failed to show submenu:', err);
return;
}
log('Submenu displayed successfully');
// Set up selection handler for the submenu
if (result && result.on) {
result.on('select', (selection) => {
log('Submenu item selected:', selection);
try {
if (selection && selection.action && selection.action.data && selection.action.data.utterance && selection.action.data.utterance.entities) {
const destination = selection.action.data.utterance.entities.destination;
if (destination) {
this.redirectToSkill(destination);
}
}
} catch (e) {
warn('Submenu selection handler error:', e && e.message ? e.message : e);
}
});
}
});
return;
}
warn('Submenu config not found:', skill);
return;
}
// Handle dynamic skills (prefixed with 'dynamic:')
if (skill && strStartsWith(skill, 'dynamic:')) {
const actualSkillId = skill.substring(8); // Remove 'dynamic:' prefix
log('Launching dynamic skill:', actualSkillId);
this.skillChosen = true;
if (_jibo && _jibo.face && _jibo.face.views) {
_jibo.face.views.changeView({
remove: true,
leaveEmpty: true,
transitionClose: _jibo.face.views.TRANSITION.UP
}, () => {
// Try to find and launch the skill
if (be.skills[actualSkillId]) {
log('Found skill as:', actualSkillId);
this.redirect(actualSkillId, {});
} else if (be.skills['@be/' + actualSkillId]) {
log('Found skill as:', '@be/' + actualSkillId);
this.redirect('@be/' + actualSkillId, {});
} else {
warn('Skill not found:', actualSkillId);
warn('Available skills:', Object.keys(be.skills).join(', '));
this.redirect('@be/chitchat', {
NLParse: {
domain: 'chitchat',
intent: 'scripted',
mimId: 'JBO_ImSorryIDidntUnderstandThat'
}
});
}
});
}
return;
}
// For all other skills, use the original method
return originalRedirectToSkill(skill);
};
log('main-menu skill redirectToSkill patched successfully');
}
/**
* Apply the patch to the BE instance
*/
function applyPatch(be) {
log('applyPatch called');
_beInstance = be;
if (be && be.log) {
be.log.info('[MENU-PATCH] initialized');
}
// Test scanning immediately
log('Testing skill scan on startup...');
const testEntries = scanAllMenuEntries();
log('Startup scan found', testEntries.length, 'entries');
testEntries.forEach(e => log(' -', e.title, '(' + e.type + ')'));
// Patch the main-menu skill after a short delay to ensure it's loaded
setTimeout(() => {
patchMainMenuSkill(be);
}, 100);
}
/**
* Patch jibo.loader.load to intercept menu loading
*/
function patchLoader(jibo, skillsRoot) {
log('patchLoader called');
_jibo = jibo;
// Allow caller to override skills root.
if (skillsRoot && typeof skillsRoot === 'string') {
SKILLS_ROOT = skillsRoot;
} else {
SKILLS_ROOT = skillsRootUtil.resolveSkillsRoot();
}
clearCache();
if (!jibo) {
warn('jibo is null/undefined');
return;
}
if (!jibo.loader) {
warn('jibo.loader is null/undefined');
return;
}
if (!jibo.loader.load) {
warn('jibo.loader.load is null/undefined');
return;
}
const originalLoad = jibo.loader.load.bind(jibo.loader);
jibo.loader.load = function(resourcePath, callback) {
// Log all resource loads to see what's happening
log('>>> jibo.loader.load called with:', resourcePath);
// Check if this is loading a dynamic submenu
if (resourcePath && strStartsWith(resourcePath, 'dynamic-submenu-')) {
const submenuConfig = _submenuConfigs[resourcePath];
if (submenuConfig) {
log('Loading dynamic submenu:', resourcePath);
if (callback) {
callback(null, submenuConfig);
}
return;
}
}
// Check if this is the main menu - match various possible paths
const isMainMenu = resourcePath && (
strIncludes(resourcePath, 'main-menu-verbal.json') ||
strIncludes(resourcePath, 'main-menu.json') ||
resourcePath === 'resources/views/main-menu-verbal.json' ||
strEndsWith(resourcePath, 'main-menu-verbal.json')
);
if (isMainMenu) {
log('*** INTERCEPTING MAIN MENU LOAD ***');
log('Resource path:', resourcePath);
originalLoad(resourcePath, function(err, config) {
if (err) {
warn('Error loading original menu config:', err);
if (callback) callback(err);
return;
}
log('Original menu config loaded successfully');
try {
const origCount = (config && config.viewConfig && config.viewConfig.list && config.viewConfig.list.length) ? config.viewConfig.list.length : 0;
log('Original button count:', origCount);
} catch (e) {
log('Original button count: (unknown)');
}
// Inject dynamic skills
const patchedConfig = injectDynamicSkills(config);
try {
const patchedCount = (patchedConfig && patchedConfig.viewConfig && patchedConfig.viewConfig.list && patchedConfig.viewConfig.list.length) ? patchedConfig.viewConfig.list.length : 0;
log('Patched button count:', patchedCount);
} catch (e) {
log('Patched button count: (unknown)');
}
if (callback) callback(null, patchedConfig);
});
return;
}
// For all other resources, use original loader
return originalLoad(resourcePath, callback);
};
log('jibo.loader.load patched successfully');
}
/**
* Clear the cached menu entries (useful for rescanning)
*/
function clearCache() {
_cachedMenuEntries = null;
_cachedSkillsRoot = null;
_cachedProvidersDir = null;
_submenuConfigs = {};
_dynamicSkillIds.clear();
}
/**
* Get the currently scanned menu entries (for debugging)
*/
function getMenuEntries() {
return scanAllMenuEntries();
}
/**
* Check if a skill ID is a dynamic skill
*/
function isDynamicSkill(skillId) {
return _dynamicSkillIds.has(skillId);
}
module.exports = {
applyPatch: applyPatch,
patchLoader: patchLoader,
clearCache: clearCache,
getMenuEntries: getMenuEntries,
isDynamicSkill: isDynamicSkill,
SKILLS_ROOT: SKILLS_ROOT
};

View File

@@ -0,0 +1,72 @@
"use strict";
const fs = require("fs");
const path = require("path");
const skillsRoot = require("./skills-root");
const scanner = require("./menu-entry-scanner");
const providers = require("./menu-providers");
function isDirectory(p) {
try {
return fs.statSync(p).isDirectory();
} catch (e) {
return false;
}
}
function expandSubmenuChildren(entries, root, opts) {
(entries || []).forEach(function (e) {
if (!e || e.type !== "submenu") return;
if (e.children && e.children.length) return;
if (!e.childrenDir) return;
let dir = e.childrenDir;
if (typeof dir !== "string" || dir.trim().length === 0) return;
dir = dir.trim();
const resolved = (dir.charAt(0) === "/") ? dir : path.join(root, dir);
if (!isDirectory(resolved)) {
if (opts && opts.log) opts.log("submenu childrenDir not a directory", e.id, resolved);
e.children = [];
return;
}
if (opts && opts.log) opts.log("submenu childrenDir scan", e.id, resolved);
e.children = scanner.scanSubmenuChildren(resolved, { log: opts && opts.log, defaultIcon: opts && opts.defaultIcon, defaultColor: opts && opts.defaultColor, defaultOrder: opts && opts.defaultOrder });
});
}
function getMenuEntries(opts) {
opts = opts || {};
const root = skillsRoot.resolveSkillsRoot(opts.skillsRoot);
const providerDir = opts.providersDir || skillsRoot.providersDirForRoot(root);
const scanned = scanner.scanAllMenuEntries(root, {
log: opts.log,
defaultIcon: opts.defaultIcon,
defaultColor: opts.defaultColor,
defaultOrder: opts.defaultOrder
});
const provided = providers.loadProviderEntries(providerDir, { log: opts.log, skillsRoot: root, providersDir: providerDir });
const merged = providers.mergeById(scanned, provided);
// Allow provider-defined submenus to be backed by an arbitrary directory.
// Example: { type: "submenu", id: "fun", childrenDir: "FunStuff" }
expandSubmenuChildren(merged, root, opts);
const dynamicIds = scanner.collectDynamicSkillIds(merged);
return {
skillsRoot: root,
providersDir: providerDir,
entries: merged,
dynamicSkillIds: dynamicIds
};
}
module.exports = {
getMenuEntries: getMenuEntries
};

View File

@@ -0,0 +1,150 @@
"use strict";
const fs = require("fs");
const path = require("path");
function safeReadJson(filePath, logFn) {
try {
const txt = fs.readFileSync(filePath, "utf8");
if (!txt || txt.trim().length === 0) return null;
return JSON.parse(txt);
} catch (e) {
if (logFn) logFn("safeReadJson failed", filePath, e && e.message ? e.message : e);
return null;
}
}
function isDirectory(p) {
try {
return fs.statSync(p).isDirectory();
} catch (e) {
return false;
}
}
function scanSkillEntry(skillDir, folderName, opts) {
opts = opts || {};
const menuJsonPath = path.join(skillDir, "menuEntry.json");
const menuJson = safeReadJson(menuJsonPath, opts.log);
if (!menuJson) return null;
if (menuJson.hidden === true) return null;
const entry = {
id: folderName,
type: menuJson.type || "skill",
title: menuJson.title || menuJson.label || folderName,
icon: menuJson.icon || menuJson.iconSrc || (opts.defaultIcon || "resources/icons/settings.png"),
color: menuJson.color || menuJson.colors || (opts.defaultColor || "default"),
description: menuJson.description || "",
path: skillDir,
order: typeof menuJson.order === "number" ? menuJson.order : (typeof opts.defaultOrder === "number" ? opts.defaultOrder : 100),
skillId: menuJson.skillId || folderName,
submenuTitle: menuJson.submenuTitle || menuJson.title || folderName
};
if (entry.type === "submenu") {
entry.children = scanSubmenuChildren(skillDir, opts);
}
// Legacy support
if (menuJson.isSubmenu === true && entry.type !== "submenu") {
entry.type = "submenu";
entry.children = scanSubmenuChildren(skillDir, opts);
}
return entry;
}
function scanSubmenuChildren(submenuDir, opts) {
const children = [];
let items;
try {
items = fs.readdirSync(submenuDir);
} catch (e) {
return children;
}
items.forEach(function (name) {
if (!name || name.charAt(0) === ".") return;
if (name === "@be" || name === "node_modules") return;
const childPath = path.join(submenuDir, name);
if (!isDirectory(childPath)) return;
const entry = scanSkillEntry(childPath, name, opts);
if (entry && entry.type === "skill") {
children.push(entry);
}
});
children.sort(function (a, b) {
if (a.order !== b.order) return a.order - b.order;
return String(a.title || "").localeCompare(String(b.title || ""));
});
return children;
}
function scanAllMenuEntries(skillsRoot, opts) {
opts = opts || {};
const entries = [];
if (!skillsRoot || !fs.existsSync(skillsRoot)) {
if (opts.log) opts.log("skills root missing", skillsRoot);
return entries;
}
let children;
try {
children = fs.readdirSync(skillsRoot);
} catch (e) {
if (opts.log) opts.log("failed to read skills root", skillsRoot, e && e.message ? e.message : e);
return entries;
}
children.forEach(function (name) {
if (!name || name.charAt(0) === ".") return;
if (name === "@be" || name === "node_modules") return;
const skillDir = path.join(skillsRoot, name);
if (!isDirectory(skillDir)) return;
const entry = scanSkillEntry(skillDir, name, opts);
if (entry) entries.push(entry);
});
entries.sort(function (a, b) {
if (a.order !== b.order) return a.order - b.order;
return String(a.title || "").localeCompare(String(b.title || ""));
});
return entries;
}
function collectDynamicSkillIds(entries) {
const ids = new Set();
(entries || []).forEach(function (e) {
if (!e) return;
if (e.type === "skill") {
if (e.skillId) ids.add(e.skillId);
if (e.id) ids.add(e.id);
}
if (e.type === "submenu" && e.children && e.children.length) {
e.children.forEach(function (c) {
if (!c) return;
if (c.skillId) ids.add(c.skillId);
if (c.id) ids.add(c.id);
});
}
});
return ids;
}
module.exports = {
scanAllMenuEntries: scanAllMenuEntries,
scanSubmenuChildren: scanSubmenuChildren,
collectDynamicSkillIds: collectDynamicSkillIds
};

View File

@@ -0,0 +1,335 @@
"use strict";
/**
* Menu Manager - Handles showing menus and submenus on Jibo's screen
*
* Supports:
* - Showing static menu definitions from JSON files
* - Dynamically scanning the Skills directory for menuEntry.json files
* - Submenu navigation with automatic back button
*
* Usage:
* const mm = require('./menu/menu-manager');
* mm.showMenuFromSkills('/opt/jibo/Jibo/Skills'); // Show main menu from skills
* mm.showSubmenu('/opt/jibo/Jibo/Skills/MySubmenu'); // Show specific submenu
*/
const path = require('path');
const fs = require('fs');
const skillsScanner = require(path.join(__dirname, 'skills-scanner'));
// Track menu history for back navigation
let menuHistory = [];
let currentSkillsRoot = '/opt/jibo/Jibo/Skills';
/**
* Build a view configuration object from a menu definition
* @param {object} menuDef - Menu definition with buttons array
* @param {object} options - Additional options (isSubmenu, parentPath, etc.)
* @returns {object} View configuration for jibo.face.views.changeView
*/
function buildViewConfig(menuDef, options = {}) {
const viewConfig = {
viewConfig: {
type: 'MenuView',
id: menuDef.id || 'menu',
title: menuDef.title || null,
ignoreSwipeDown: menuDef.allowSwipe === false ? true : false,
listDefault: menuDef.listDefault || { menuButtonType: 'ActionButton', colors: 'default' },
list: [],
},
open: menuDef.open || {},
defaultClose: menuDef.defaultClose || { remove: true, transitionClose: 'trans_down' },
defaultSelect: menuDef.defaultSelect || { remove: true, transitionClose: 'trans_down' }
};
// Add back button for submenus
if (options.isSubmenu || menuDef.isSubmenu) {
viewConfig.viewConfig.list.push({
id: '__back__',
label: '← Back',
iconSrc: 'core://resources/actionIcons/back.png',
colors: 'default',
actions: [{ type: 'be:menu_back' }]
});
}
// Process each button in the menu
(menuDef.buttons || []).forEach(b => {
const item = {
id: b.id || b.label,
label: b.label || b.id,
iconSrc: b.icon || b.iconSrc || skillsScanner.DEFAULT_ICON,
colors: b.colors || b.color || menuDef.colors || 'default',
};
// Determine actions based on button type
if (b.actions || b.events) {
// Explicit actions provided
item.actions = b.actions || b.events;
} else if (b.skillId) {
// Regular skill - launch it
item.actions = [{ type: 'be:launch', data: { skillId: b.skillId } }];
} else if (b.submenu && Array.isArray(b.submenu)) {
// Submenu with embedded buttons - open inline submenu
item.actions = [{
type: 'be:open_submenu',
data: {
submenu: b.submenu,
submenuTitle: b.submenuTitle || b.label,
submenuPath: b.submenuPath
}
}];
} else if (b.submenuPath) {
// Submenu by path - open from disk
item.actions = [{
type: 'be:open_submenu_path',
data: { submenuPath: b.submenuPath }
}];
} else if (b.utterance) {
// Legacy utterance-based action
item.actions = [{ type: 'utterance', data: { utterance: b.utterance } }];
} else {
// No action defined
item.actions = [];
}
// Copy hit area polygon if defined (for custom button shapes)
if (b.hitAreaPolygon) {
item.hitAreaPolygon = b.hitAreaPolygon;
}
viewConfig.viewConfig.list.push(item);
});
return viewConfig;
}
/**
* Handle menu item selection events
* @param {object} selection - The selected menu item
* @param {function} cb - Callback function
*/
function handleMenuSelection(selection, cb) {
if (!selection || !selection.actions || !selection.actions.length) {
if (cb) cb(null, selection);
return;
}
const action = selection.actions[0];
switch (action.type) {
case 'be:menu_back':
// Go back to previous menu
goBack(cb);
break;
case 'be:open_submenu':
// Open inline submenu with embedded data
if (action.data && action.data.submenu) {
showInlineSubmenu(action.data.submenu, action.data.submenuTitle, action.data.submenuPath, cb);
}
break;
case 'be:open_submenu_path':
// Open submenu from disk path
if (action.data && action.data.submenuPath) {
exports.showSubmenu(action.data.submenuPath, cb);
}
break;
case 'be:launch':
// Launch skill - let the BE framework handle this
if (cb) cb(null, selection);
break;
default:
if (cb) cb(null, selection);
}
}
/**
* Show an inline submenu with embedded button data
* @param {Array} submenuButtons - Array of button definitions
* @param {string} title - Submenu title
* @param {string} submenuPath - Path for history tracking
* @param {function} cb - Callback
*/
function showInlineSubmenu(submenuButtons, title, submenuPath, cb) {
// Save current state to history
menuHistory.push({ type: 'main', path: currentSkillsRoot });
const submenuDef = {
id: 'submenu-' + (title || 'sub').toLowerCase().replace(/\s+/g, '-'),
title: title || 'Menu',
isSubmenu: true,
buttons: submenuButtons.map(b => ({
id: b.id,
label: b.label,
icon: b.icon || b.iconSrc,
colors: b.colors || b.color,
skillId: b.skillId,
description: b.description
}))
};
const viewConfig = buildViewConfig(submenuDef, { isSubmenu: true });
if (typeof jibo === 'undefined' || !jibo.face || !jibo.face.views) {
if (cb) cb(new Error('jibo not initialized'));
return;
}
jibo.face.views.changeView(viewConfig, function(err, result) {
if (err) {
if (cb) cb(err);
return;
}
// Set up selection handler for submenu
if (result && result.on) {
result.on('select', function(selection) {
handleMenuSelection(selection, cb);
});
}
if (cb) cb(null, result);
});
}
/**
* Go back to the previous menu in history
* @param {function} cb - Callback
*/
function goBack(cb) {
const previous = menuHistory.pop();
if (!previous) {
// No history, show main menu
exports.showMenuFromSkills(currentSkillsRoot, cb);
return;
}
if (previous.type === 'main') {
exports.showMenuFromSkills(previous.path || currentSkillsRoot, cb);
} else if (previous.type === 'submenu' && previous.path) {
exports.showSubmenu(previous.path, cb);
} else {
exports.showMenuFromSkills(currentSkillsRoot, cb);
}
}
/**
* Show a menu from a static JSON file
* @param {string} menuPath - Relative path to menu JSON file
* @param {function} cb - Callback
*/
exports.showMenu = function (menuPath, cb) {
try {
const menuDef = require(path.join(__dirname, '..', menuPath));
const viewConfig = buildViewConfig(menuDef);
if (typeof jibo === 'undefined' || !jibo.face || !jibo.face.views) {
throw new Error('jibo not initialized');
}
jibo.face.views.changeView(viewConfig, cb || function(){});
}
catch (e) {
console.warn('menu-manager.showMenu error', e);
if (cb) cb(e);
}
};
/**
* Show the main menu by scanning the Skills directory for menuEntry.json files
* @param {string} skillsRootPath - Path to Skills directory
* @param {function} cb - Callback
*/
exports.showMenuFromSkills = function (skillsRootPath, cb) {
try {
const root = skillsRootPath || '/opt/jibo/Jibo/Skills';
currentSkillsRoot = root;
menuHistory = []; // Reset history when showing main menu
const menuDef = skillsScanner.scanSkills(root);
const viewConfig = buildViewConfig(menuDef);
if (typeof jibo === 'undefined' || !jibo.face || !jibo.face.views) {
throw new Error('jibo not initialized');
}
jibo.face.views.changeView(viewConfig, function(err, result) {
if (err) {
console.warn('menu-manager.showMenuFromSkills error', err);
if (cb) cb(err);
return;
}
// Set up selection handler
if (result && result.on) {
result.on('select', function(selection) {
handleMenuSelection(selection, cb);
});
}
if (cb) cb(null, result);
});
}
catch (e) {
console.warn('menu-manager.showMenuFromSkills error', e);
if (cb) cb(e);
}
};
/**
* Show a submenu by scanning a specific directory
* @param {string} submenuPath - Full path to the submenu directory
* @param {function} cb - Callback
*/
exports.showSubmenu = function (submenuPath, cb) {
try {
// Save current state to history
if (menuHistory.length === 0) {
menuHistory.push({ type: 'main', path: currentSkillsRoot });
}
const menuDef = skillsScanner.getSubmenu(submenuPath);
const viewConfig = buildViewConfig(menuDef, { isSubmenu: true });
if (typeof jibo === 'undefined' || !jibo.face || !jibo.face.views) {
throw new Error('jibo not initialized');
}
jibo.face.views.changeView(viewConfig, function(err, result) {
if (err) {
console.warn('menu-manager.showSubmenu error', err);
if (cb) cb(err);
return;
}
// Set up selection handler
if (result && result.on) {
result.on('select', function(selection) {
handleMenuSelection(selection, cb);
});
}
if (cb) cb(null, result);
});
}
catch (e) {
console.warn('menu-manager.showSubmenu error', e);
if (cb) cb(e);
}
};
/**
* Clear menu history (useful when closing menu completely)
*/
exports.clearHistory = function() {
menuHistory = [];
};
/**
* Get current menu history (for debugging)
*/
exports.getHistory = function() {
return menuHistory.slice();
};
/**
* Get the skills scanner module for direct access
*/
exports.skillsScanner = skillsScanner;

View File

@@ -0,0 +1,115 @@
"use strict";
const fs = require("fs");
const path = require("path");
function isDirectory(p) {
try {
return fs.statSync(p).isDirectory();
} catch (e) {
return false;
}
}
function safeReadJson(filePath, logFn) {
try {
const txt = fs.readFileSync(filePath, "utf8");
if (!txt || txt.trim().length === 0) return null;
return JSON.parse(txt);
} catch (e) {
if (logFn) logFn("provider json read failed", filePath, e && e.message ? e.message : e);
return null;
}
}
function loadFromJson(filePath, ctx) {
const obj = safeReadJson(filePath, ctx && ctx.log);
if (!obj) return [];
if (Array.isArray(obj)) return obj;
if (obj && Array.isArray(obj.entries)) return obj.entries;
if (obj && Array.isArray(obj.buttons)) return obj.buttons;
return [];
}
function loadFromJs(filePath, ctx) {
try {
// eslint-disable-next-line global-require, import/no-dynamic-require
const mod = require(filePath);
if (!mod) return [];
if (typeof mod === "function") return mod(ctx) || [];
if (typeof mod.getEntries === "function") return mod.getEntries(ctx) || [];
if (Array.isArray(mod.entries)) return mod.entries;
return [];
} catch (e) {
if (ctx && ctx.log) ctx.log("provider js load failed", filePath, e && e.message ? e.message : e);
return [];
}
}
function loadProviderEntries(providersDir, ctx) {
const out = [];
if (!providersDir || !fs.existsSync(providersDir) || !isDirectory(providersDir)) {
return out;
}
let files;
try {
files = fs.readdirSync(providersDir);
} catch (e) {
return out;
}
files.sort();
files.forEach(function (name) {
if (!name || name.charAt(0) === ".") return;
const full = path.join(providersDir, name);
if (isDirectory(full)) return;
if (name.indexOf(".json", name.length - 5) !== -1) {
const entries = loadFromJson(full, ctx);
entries.forEach(function (e) { out.push(e); });
return;
}
if (name.indexOf(".js", name.length - 3) !== -1) {
const entries = loadFromJs(full, ctx);
entries.forEach(function (e) { out.push(e); });
}
});
return out;
}
// Merge provider entries into scanned entries by id.
// Provider entries win on conflicts.
function mergeById(scannedEntries, providerEntries) {
const byId = {};
(scannedEntries || []).forEach(function (e) {
if (!e || !e.id) return;
byId[e.id] = e;
});
(providerEntries || []).forEach(function (p) {
if (!p || !p.id) return;
byId[p.id] = p;
});
const merged = Object.keys(byId).map(function (k) { return byId[k]; });
merged.sort(function (a, b) {
const ao = typeof a.order === "number" ? a.order : 100;
const bo = typeof b.order === "number" ? b.order : 100;
if (ao !== bo) return ao - bo;
return String(a.title || a.label || a.id).localeCompare(String(b.title || b.label || b.id));
});
return merged;
}
module.exports = {
loadProviderEntries: loadProviderEntries,
mergeById: mergeById
};

View File

@@ -0,0 +1,25 @@
{
"id": "exampleMenu",
"title": "Example Menu",
"buttons": [
{
"id": "one",
"label": "One",
"icon": "core://resources/actionIcons/default.png",
"utterance": "one"
},
{
"id": "two",
"label": "Two",
"icon": "core://resources/actionIcons/default.png",
"utterance": "two"
},
{
"id": "hex",
"label": "Hex",
"icon": "core://resources/actionIcons/default.png",
"utterance": "hex",
"hitAreaPolygon": [165,0,330,82.5,330,247.5,165,330,0,247.5,0,82.5]
}
]
}

View File

@@ -0,0 +1,7 @@
{
"id": "@be/rosbridge",
"title": "ROS Bridge",
"description": "Connect robot to ROS via rosbridge websocket",
"entryPoint": "@be/rosbridge",
"hidden": true
}

View File

@@ -0,0 +1,31 @@
"use strict";
const path = require("path");
const fs = require("fs");
const DEFAULT_ROOT = "/opt/jibo/Jibo/Skills";
function resolveSkillsRoot(overrideRoot) {
if (overrideRoot && typeof overrideRoot === "string") return overrideRoot;
if (process && process.env && process.env.JIBO_SKILLS_ROOT) return process.env.JIBO_SKILLS_ROOT;
return DEFAULT_ROOT;
}
function providersDirForRoot(skillsRoot) {
// Keep providers inside Skills so they can be synced easily.
// Default: /opt/jibo/Jibo/Skills/@be/menu-entries.d
// Legacy fallback: /opt/jibo/Jibo/Skills/@be/menu-providers.d
const v2 = path.join(skillsRoot, "@be", "menu-entries.d");
const v1 = path.join(skillsRoot, "@be", "menu-providers.d");
try {
if (fs.existsSync(v2)) return v2;
if (fs.existsSync(v1)) return v1;
} catch (e) { /* ignore */ }
return v2;
}
module.exports = {
DEFAULT_ROOT: DEFAULT_ROOT,
resolveSkillsRoot: resolveSkillsRoot,
providersDirForRoot: providersDirForRoot
};

View File

@@ -0,0 +1,305 @@
"use strict";
/**
* Skills Scanner - Scans the Skills directory for menuEntry.json files
*
* menuEntry.json Schema:
* {
* "type": "skill" | "submenu", // Required: "skill" for launchable skill, "submenu" for folder containing skills
* "title": "Display Name", // Required: Button label shown on Jibo's screen
* "icon": "path/to/icon.png", // Required: Icon path (relative to skill folder or core:// URL)
* "color": "blue", // Optional: Button color theme (default, blue, green, red, purple, orange, etc.)
* "skillId": "@namespace/skill-id", // Required for type="skill": The skill ID to launch
* "description": "Short description",// Optional: Tooltip/accessibility text
* "order": 0, // Optional: Sort order (lower = first, default: 100)
* "hidden": false // Optional: Hide from menu (default: false)
* }
*/
const fs = require('fs');
const path = require('path');
// Default icon path for skills without custom icons (relative to @be/main-menu)
const DEFAULT_ICON = 'resources/icons/settings.png';
const DEFAULT_SUBMENU_ICON = 'resources/icons/fun-stuff.png';
const DEFAULT_COLOR = 'default';
const DEFAULT_ORDER = 100;
/**
* Safely read and parse a JSON file
* @param {string} filePath - Path to JSON file
* @returns {object|null} Parsed JSON or null on error
*/
function safeReadJson(filePath) {
try {
const txt = fs.readFileSync(filePath, 'utf8');
if (!txt || txt.trim().length === 0) return null;
return JSON.parse(txt);
}
catch (e) {
console.warn('skills-scanner: failed to read json', filePath, e.message);
return null;
}
}
/**
* Check if path is a directory
* @param {string} p - Path to check
* @returns {boolean}
*/
function isDirectory(p) {
try {
return fs.statSync(p).isDirectory();
}
catch (e) { return false; }
}
/**
* Scan a single skill directory and return menu entry
* @param {string} skillDir - Full path to skill directory
* @param {string} folderName - Name of the folder
* @returns {object|null} Menu entry object or null if invalid
*/
function scanSkillEntry(skillDir, folderName) {
const menuJsonPath = path.join(skillDir, 'menuEntry.json');
const menuJson = safeReadJson(menuJsonPath);
if (!menuJson) {
// No menuEntry.json - check if it's a valid skill by looking for package.json or index.html
const hasPackage = fs.existsSync(path.join(skillDir, 'package.json'));
const hasIndex = fs.existsSync(path.join(skillDir, 'index.html'));
if (hasPackage || hasIndex) {
// Auto-generate entry for legacy skills
return {
id: folderName,
type: 'skill',
title: folderName,
icon: DEFAULT_ICON,
color: DEFAULT_COLOR,
skillId: folderName,
path: skillDir,
order: DEFAULT_ORDER,
hidden: false
};
}
return null;
}
// Validate required fields
if (menuJson.hidden === true) {
return null; // Skip hidden entries
}
const entry = {
id: folderName,
type: menuJson.type || 'skill',
title: menuJson.title || menuJson.label || folderName,
icon: menuJson.icon || menuJson.iconSrc || (menuJson.type === 'submenu' ? DEFAULT_SUBMENU_ICON : DEFAULT_ICON),
color: menuJson.color || menuJson.colors || DEFAULT_COLOR,
description: menuJson.description || '',
path: skillDir,
order: typeof menuJson.order === 'number' ? menuJson.order : DEFAULT_ORDER,
hidden: menuJson.hidden === true
};
// For skills, include skillId
if (entry.type === 'skill') {
entry.skillId = menuJson.skillId || folderName;
}
// For submenus, scan child directories
if (entry.type === 'submenu') {
entry.submenu = scanSubmenuChildren(skillDir);
// Copy submenu-specific properties
if (menuJson.submenuTitle) {
entry.submenuTitle = menuJson.submenuTitle;
}
}
// Support for legacy isSubmenu flag
if (menuJson.isSubmenu === true && entry.type !== 'submenu') {
entry.type = 'submenu';
entry.submenu = scanSubmenuChildren(skillDir);
}
return entry;
}
/**
* Scan children of a submenu directory
* @param {string} submenuDir - Path to submenu directory
* @returns {Array} Array of skill entries
*/
function scanSubmenuChildren(submenuDir) {
const children = [];
let items;
try {
items = fs.readdirSync(submenuDir);
} catch (e) {
return children;
}
items.forEach(name => {
// Skip hidden folders and @be
if (name.startsWith('.') || name === '@be') return;
const childPath = path.join(submenuDir, name);
if (!isDirectory(childPath)) return;
const entry = scanSkillEntry(childPath, name);
if (entry && entry.type === 'skill') {
children.push(entry);
}
// Note: Nested submenus within submenus are not supported (single level only)
});
// Sort by order, then alphabetically
children.sort((a, b) => {
if (a.order !== b.order) return a.order - b.order;
return a.title.localeCompare(b.title);
});
return children;
}
/**
* Scan the Skills root directory for all menu entries
* @param {string} rootDir - Path to Skills directory
* @returns {Array} Array of menu entries
*/
function scanDirForMenuEntries(rootDir) {
const entries = [];
let children;
try {
children = fs.readdirSync(rootDir);
} catch (e) {
console.warn('skills-scanner: failed to read skills directory', rootDir, e.message);
return entries;
}
children.forEach(name => {
// Skip hidden folders and @be (it's the core brain, not a menu item)
if (name.startsWith('.') || name === '@be') return;
const skillDir = path.join(rootDir, name);
if (!isDirectory(skillDir)) return;
const entry = scanSkillEntry(skillDir, name);
if (entry) {
entries.push(entry);
}
});
// Sort by order, then alphabetically
entries.sort((a, b) => {
if (a.order !== b.order) return a.order - b.order;
return a.title.localeCompare(b.title);
});
return entries;
}
/**
* Scan skills directory and return menu definition
* @param {string} rootDir - Path to Skills directory
* @returns {object} Menu definition with buttons array
*/
function scanSkills(rootDir) {
const entries = scanDirForMenuEntries(rootDir);
const menuDef = {
id: 'main-menu',
title: 'Apps',
buttons: []
};
entries.forEach(entry => {
const button = {
id: entry.id,
label: entry.title,
icon: entry.icon,
colors: entry.color,
description: entry.description
};
if (entry.type === 'submenu' && entry.submenu) {
// For submenus, include the submenu array and metadata
button.submenu = entry.submenu.map(s => ({
id: s.id,
label: s.title,
icon: s.icon,
colors: s.color,
skillId: s.skillId,
description: s.description
}));
button.submenuTitle = entry.submenuTitle || entry.title;
button.submenuPath = entry.path;
} else {
// Regular skill
button.skillId = entry.skillId;
}
menuDef.buttons.push(button);
});
return menuDef;
}
/**
* Get a specific submenu's contents by path
* @param {string} submenuPath - Path to the submenu directory
* @returns {object} Menu definition for the submenu
*/
function getSubmenu(submenuPath) {
const menuJsonPath = path.join(submenuPath, 'menuEntry.json');
const menuJson = safeReadJson(menuJsonPath) || {};
const folderName = path.basename(submenuPath);
const entries = scanSubmenuChildren(submenuPath);
return {
id: folderName + '-submenu',
title: menuJson.submenuTitle || menuJson.title || folderName,
isSubmenu: true,
parentPath: path.dirname(submenuPath),
buttons: entries.map(entry => ({
id: entry.id,
label: entry.title,
icon: entry.icon,
colors: entry.color,
skillId: entry.skillId,
description: entry.description
}))
};
}
/**
* Get all registered skills (flat list for skill loading)
* @param {string} rootDir - Path to Skills directory
* @returns {Array} Array of skill IDs
*/
function getAllSkillIds(rootDir) {
const skillIds = [];
const entries = scanDirForMenuEntries(rootDir);
entries.forEach(entry => {
if (entry.type === 'skill' && entry.skillId) {
skillIds.push(entry.skillId);
} else if (entry.type === 'submenu' && entry.submenu) {
entry.submenu.forEach(s => {
if (s.skillId) skillIds.push(s.skillId);
});
}
});
return skillIds;
}
module.exports = {
scanSkills,
scanDirForMenuEntries,
getSubmenu,
getAllSkillIds,
DEFAULT_ICON,
DEFAULT_SUBMENU_ICON,
DEFAULT_COLOR
};