aboutsummaryrefslogtreecommitdiff
path: root/modules/ext/course.js
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-10-18 14:05:18 +0200
committerAgando <roduccomm@gmail.com>2026-10-18 14:05:18 +0200
commit0d0d936e7b5cd41a31773069540b1ddb8836ef44 (patch)
tree273e7ee9a72453655119bf0f63efb9b61e193d7f /modules/ext/course.js
parent526c420f4591f0a58f805b473fa76d3da2981eae (diff)
newOS4: interactive AI course (terminal module, portal, atlas, telemetry, daily maintenance+cron) + free store card
Diffstat (limited to 'modules/ext/course.js')
-rw-r--r--modules/ext/course.js323
1 files changed, 323 insertions, 0 deletions
diff --git a/modules/ext/course.js b/modules/ext/course.js
new file mode 100644
index 0000000..3879cb5
--- /dev/null
+++ b/modules/ext/course.js
@@ -0,0 +1,323 @@
+class CourseModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ this.BASE = '/spaces/newos4/course/';
+ this.STATE_KEY = 'webx11_course_progress_v1';
+ this._manifest = null;
+ this._atlas = null;
+ this._chapters = {};
+ this._active = null;
+ this._capOut = '';
+ this._capActive = false;
+ }
+
+ getHelpInfo() {
+ return {
+ 'course': 'course [start|list|next|open <id>|check|hint|progress|reset|ask <q>|project [id]|cmds [q]] — Interactive newOS4 course'
+ };
+ }
+
+ registerCommands() {
+ this.commands['course'] = (args) => this.handle(args || []);
+ this._warm();
+ }
+
+ _v() {
+ const mod = localStorage.getItem('webx11_cache_ver') || '1';
+ return mod + '-' + new Date().toISOString().slice(0, 10);
+ }
+
+ async _warm() {
+ try { await this._load(); await this._installSkills(); } catch (e) { /* offline: commands report on use */ }
+ }
+
+ async _load(force) {
+ if (this._manifest && !force) return this._manifest;
+ const bust = this._v();
+ const [m, a] = await Promise.all([
+ fetch(this.BASE + 'course.json?v=' + bust).then(r => r.json()),
+ fetch(this.BASE + 'atlas.json?v=' + bust).then(r => r.json()).catch(() => null)
+ ]);
+ this._manifest = m;
+ this._atlas = a;
+ return m;
+ }
+
+ async _chapter(id) {
+ if (this._chapters[id]) return this._chapters[id];
+ const ch = await fetch(this.BASE + 'chapters/' + id + '.json?v=' + this._v()).then(r => r.json());
+ this._chapters[id] = ch;
+ return ch;
+ }
+
+ _progress() {
+ try { return JSON.parse(localStorage.getItem(this.STATE_KEY)) || { completed: {}, exercisePass: {}, xp: 0 }; }
+ catch (e) { return { completed: {}, exercisePass: {}, xp: 0 }; }
+ }
+
+ _save(p) { localStorage.setItem(this.STATE_KEY, JSON.stringify(p)); }
+
+ _printLesson(l, chTitle) {
+ const t = this.terminal;
+ t.print('┌─ ' + chTitle + ' › ' + l.title + ' ─', 'header');
+ t.print(' 🎯 ' + (l.objective || ''), 'info');
+ t.print(' ⏱ ' + (l.minutes || 5) + ' min · commands: ' + (l.commands || []).join(', '), 'text-muted');
+ t.print('');
+ for (const b of (l.blocks || [])) {
+ if (b.type === 'cmd') t.print(' $ ' + b.text, 'success');
+ else if (b.type === 'code') t.print(' ' + b.text, 'info');
+ else if (b.type === 'tip') t.print(' 💡 ' + b.text, 'warning');
+ else if (b.type === 'warn') t.print(' ⚠️ ' + b.text, 'error');
+ else t.print(' ' + b.text, '');
+ }
+ if (l.exercise) {
+ t.print('');
+ t.print(' 🏋️ Exercise: ' + l.exercise.instruction, 'info');
+ t.print(' Type the command(s); completion is detected automatically (or use `course check`).', 'text-muted');
+ }
+ if (l.quiz) {
+ t.print('');
+ t.print(' ❓ ' + l.quiz.q, 'info');
+ (l.quiz.options || []).forEach((o, i) => t.print(' ' + (i + 1) + ') ' + o, 'text-muted'));
+ t.print(' Answer with: `course quiz <number>`', 'text-muted');
+ }
+ }
+
+ async _open(lessonId) {
+ const m = await this._load();
+ for (const ch of m.chapters || []) {
+ const c = await this._chapter(ch.id);
+ const l = (c.lessons || []).find(x => x.id === lessonId);
+ if (!l) continue;
+ this._active = { lessonId, chapterId: ch.id, chapterTitle: ch.title, lesson: l };
+ this._printLesson(l, ch.title);
+ this._track('lesson_open', { lesson: lessonId });
+ return true;
+ }
+ this.terminal.print('Lesson not found: ' + lessonId, 'error');
+ return false;
+ }
+
+ _flat(m) {
+ return (m.chapters || []).map(ch => ({ id: ch.id, title: ch.title, icon: ch.icon || '📘', level: ch.level || 'beginner', lessons: ch.lessons || [] }));
+ }
+
+ async handle(args) {
+ const sub = (args[0] || 'start').toLowerCase();
+ let m;
+ try { m = await this._load(); }
+ catch (e) { return this.terminal.print('Course content unreachable (' + e.message + '). Try `course list` again.', 'error'); }
+ const p = this._progress();
+ if (sub === 'list' || sub === 'chapters') {
+ this.terminal.print('📚 newOS4 Course — v' + m.version + ' (' + m.updated + ')', 'header');
+ for (const ch of this._flat(m)) {
+ const done = ch.lessons.filter(l => p.completed[l.id]).length;
+ this.terminal.print(' ' + ch.icon + ' ' + ch.id + ' · ' + ch.title + ' [' + done + '/' + ch.lessons.length + ']', done ? 'success' : '');
+ }
+ this.terminal.print(' course start | open <lessonId> | next | progress', 'text-muted');
+ return;
+ }
+ if (sub === 'start' || sub === 'next') {
+ for (const ch of this._flat(m)) for (const l of ch.lessons) if (!p.completed[l.id]) return this._open(l.id);
+ return this.terminal.print('🎉 Course complete! Try `course project` or `course cmds <query>`.', 'success');
+ }
+ if (sub === 'open') { if (!args[1]) return this.terminal.print('Usage: course open <lessonId>', 'warning'); return this._open(args[1]); }
+ if (sub === 'hint') {
+ if (!this._active) return this.terminal.print('Open a lesson first: `course start`.', 'warning');
+ return this.terminal.print('💡 ' + (this._active.lesson.exercise?.hint || 'No hint for this lesson.'), 'warning');
+ }
+ if (sub === 'progress') {
+ const total = this._flat(m).reduce((n, ch) => n + ch.lessons.length, 0);
+ const done = Object.keys(p.completed).length;
+ this.terminal.print('📈 Progress: ' + done + '/' + total + ' lessons · ' + (p.xp || 0) + ' XP', 'header');
+ const next = this._flat(m).flatMap(c => c.lessons).find(l => !p.completed[l.id]);
+ if (next) this.terminal.print(' Next: `course open ' + next.id + '` — ' + next.title, 'info');
+ return;
+ }
+ if (sub === 'reset') {
+ if (args[1] !== '--confirm') return this.terminal.print('This erases course progress. Run: course reset --confirm', 'warning');
+ localStorage.removeItem(this.STATE_KEY); this._active = null; return this.terminal.print('Course progress reset.', 'success');
+ }
+ if (sub === 'quiz') {
+ if (!this._active || !this._active.lesson.quiz) return this.terminal.print('No quiz open.', 'warning');
+ const pick = parseInt(args[1], 10) - 1;
+ const ok = pick === this._active.lesson.quiz.answer;
+ if (ok) this._complete(this._active.lessonId, 5);
+ return this.terminal.print(ok ? '✅ Correct (+5 XP)' : '❌ Not quite — re-read with `course open ' + this._active.lessonId + '`.', ok ? 'success' : 'error');
+ }
+ if (sub === 'check') return this._manualCheck();
+ if (sub === 'cmds') return this._cmdSearch(args.slice(1).join(' '));
+ if (sub === 'project') return this._project(args[1]);
+ if (sub === 'ask') return this._ask(args.slice(1).join(' '));
+ this.terminal.print('Usage: ' + this.getHelpInfo().course, 'warning');
+ }
+
+ _complete(lessonId, xp) {
+ const p = this._progress();
+ if (!p.completed[lessonId]) {
+ p.completed[lessonId] = new Date().toISOString();
+ p.xp = (p.xp || 0) + (xp || 10);
+ this._save(p);
+ this.terminal.print('✅ Lesson ' + lessonId + ' complete (+' + (xp || 10) + ' XP)', 'success');
+ this._track('lesson_complete', { lesson: lessonId });
+ }
+ }
+
+ _cmdSearch(q) {
+ if (!this._atlas) return this.terminal.print('Atlas not loaded yet — retry in a moment.', 'warning');
+ const items = Object.entries(this._atlas.commands);
+ const hit = q ? items.filter(([c, e]) => (c + ' ' + e.desc + ' ' + e.module + ' ' + e.category).toLowerCase().includes(q.toLowerCase())) : items;
+ this.terminal.print('🔎 ' + (q ? hit.length + ' match(es)' : items.length + ' commands') + ' — `course cmds <query>` to filter', 'header');
+ for (const [c, e] of hit.slice(0, 40)) this.terminal.print(' ' + c.padEnd(18) + ' ' + (e.desc || e.module).slice(0, 90), 'text-muted');
+ }
+
+ onCommandStart(cmd, args) {
+ if (cmd === 'course') return;
+ if (this._capActive) { try { this.terminal._popPrintCapture(); } catch (e) {} this._capActive = false; }
+ this._capOut = '';
+ this._capActive = true;
+ try { this.terminal._pushPrintCapture(txt => { this._capOut = txt; }); } catch (e) { this._capActive = false; }
+ }
+
+ onCommandExecuted(cmd, args) {
+ if (this._capActive) { try { this.terminal._popPrintCapture(); } catch (e) {} this._capActive = false; }
+ if (!this._active || !this._active.lesson.exercise || cmd === 'course') return;
+ const ex = this._active.lesson.exercise;
+ const line = (cmd + (args.length ? ' ' + args.join(' ') : '')).trim();
+ if (!ex.accept.some(pat => this._match(pat, line))) return;
+ this._verify(ex, line);
+ }
+
+ _match(pattern, line) {
+ const esc = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
+ return new RegExp('^' + esc + '$', 'i').test(line);
+ }
+
+ _verify(ex, line) {
+ const check = ex.check || { type: 'command' };
+ let ok = false, detail = '';
+ if (check.type === 'command' || check.type === 'manual') ok = true;
+ else if (check.type === 'file') {
+ if (!check.path) { detail = 'exercise misconfigured: check.path missing'; }
+ else {
+ let exists = false;
+ try { const c = this.terminal.fs.readFile(check.path); exists = (c !== null && c !== undefined); } catch (e) { exists = false; }
+ ok = exists; detail = ok ? '' : 'file not found: ' + check.path;
+ }
+ } else if (check.type === 'output') {
+ ok = (this._capOut || '').toLowerCase().includes((check.contains || '').toLowerCase());
+ detail = ok ? '' : 'expected output to contain "' + check.contains + '"';
+ }
+ if (ok) this._complete(this._active.lessonId, 10);
+ else { this.terminal.print(' ✗ Not done yet — ' + (detail || 'keep trying'), 'warning'); this._track('exercise_fail', { lesson: this._active.lessonId, command: line.slice(0, 120) }); }
+ }
+
+ _manualCheck() {
+ if (!this._active) return this.terminal.print('Open a lesson first: `course start`.', 'warning');
+ const ex = this._active.lesson.exercise;
+ if (!ex) return this.terminal.print('This lesson has no exercise.', 'warning');
+ this._verify(ex, '(manual check)');
+ }
+
+ async _ask(q) {
+ if (!q) return this.terminal.print('Usage: course ask <question>', 'warning');
+ const l = this._active && this._active.lesson;
+ const sys = 'You are the newOS4 course tutor. Answer in <=120 words. Use ONLY the course context and command atlas below. '
+ + 'If something is not covered, say so and point to `help <cmd>` or the relevant lesson — never invent commands.\n\n'
+ + (l ? this._lessonContext(l) : this._atlasContext(q));
+ const model = (this.terminal.ollamaManager && this.terminal.ollamaManager.currentModel) || 'gemma4:31b-cloud';
+ const tok = localStorage.getItem('webx11_session_id') || '';
+ const headers = { 'Content-Type': 'application/json' };
+ if (tok) headers.Authorization = ['Bearer', tok].join(' ');
+ this.terminal.print('🤖 thinking…', 'text-muted');
+ try {
+ const r = await fetch('/ai/v1/chat/completions', {
+ method: 'POST', headers,
+ body: JSON.stringify({ model, stream: false, messages: [{ role: 'system', content: sys }, { role: 'user', content: q }] })
+ });
+ if (!r.ok) throw new Error('HTTP ' + r.status + (r.status === 401 ? ' — log in to use the AI tutor' : ''));
+ const d = await r.json();
+ const ans = (d.choices && d.choices[0] && d.choices[0].message && d.choices[0].message.content) || '(empty answer)';
+ this.terminal.print('🎓 ' + ans.trim(), 'info');
+ this._track('question', { q: q.slice(0, 240), lesson: l ? l.id : null });
+ } catch (e) {
+ this.terminal.print('AI tutor unavailable: ' + e.message + '. Use `ai <question>` as a fallback.', 'warning');
+ }
+ }
+
+ _lessonContext(l) {
+ const lines = ['LESSON: ' + l.title, 'OBJECTIVE: ' + l.objective, 'COMMANDS: ' + (l.commands || []).join(', ')];
+ for (const b of (l.blocks || [])) lines.push(b.type.toUpperCase() + ': ' + b.text);
+ if (l.exercise) lines.push('EXERCISE: ' + l.exercise.instruction + ' | HINT: ' + l.exercise.hint);
+ if (this._atlas) for (const c of (l.commands || [])) { const e = this._atlas.commands[c]; if (e) lines.push('ATLAS ' + c + ': ' + e.desc + ' [' + e.tier + ']'); }
+ return lines.join('\n').slice(0, 6000);
+ }
+
+ _atlasContext(q) {
+ if (!this._atlas) return 'No atlas loaded.';
+ const hits = Object.entries(this._atlas.commands)
+ .filter(([c, e]) => (c + ' ' + e.desc).toLowerCase().includes((q || '').toLowerCase().split(/\s+/)[0] || ''))
+ .slice(0, 8).map(([c, e]) => c + ': ' + e.desc);
+ return 'COMMAND ATLAS (top matches):\n' + hits.join('\n');
+ }
+
+ async _project(id) {
+ const m = await this._load();
+ const ids = m.projects || [];
+ if (!id) return this.terminal.print('🛠 Projects: ' + (ids.join(', ') || 'none yet') + ' — `course project <id>`', 'info');
+ const pr = await fetch(this.BASE + 'projects/' + id + '.json?v=' + this._v()).then(r => r.json());
+ this.terminal.print('🛠 ' + pr.title + ' (' + pr.minutes + ' min) — ' + pr.goal, 'header');
+ (pr.steps || []).forEach((s, i) => this.terminal.print(' ' + (i + 1) + '. ' + s.instruction + (s.command ? ' → ' + s.command : ''), ''));
+ this._track('project_open', { project: id });
+ }
+
+ async _installSkills() {
+ const fs = this.terminal && this.terminal.fs;
+ const m = this._manifest;
+ if (!fs || !m) return;
+ const key = 'webx11_course_skills_ver';
+ if (localStorage.getItem(key) === m.version) return;
+ try {
+ fs.mkdirRecursive('/skills');
+ const index = fs.fsReadJSON('/skills/index.json') || {};
+ let n = 0;
+ for (const ch of m.chapters || []) {
+ const c = this._chapters[ch.id] || await this._chapter(ch.id);
+ const body = (c.lessons || []).map(l => '## ' + l.title + '\n' + (l.blocks || [])
+ .filter(b => b.type === 'text' || b.type === 'warn' || b.type === 'tip')
+ .map(b => b.text).join('\n')).join('\n');
+ const name = 'course-' + ch.id.replace(/[^a-z0-9-]/gi, '-').toLowerCase();
+ const content = '---\nname: ' + name + '\ndescription: newOS4 course — ' + ch.title
+ + '\ntags: [course, newos4]\ncategory: learning\n---\n' + body;
+ fs.writeFile('/skills/' + name + '.md', content);
+ index[name] = {
+ name, description: 'newOS4 course — ' + ch.title,
+ tags: ['course', 'newos4'], category: 'learning',
+ created: (index[name] && index[name].created) || new Date().toISOString(),
+ updated: new Date().toISOString()
+ };
+ n++;
+ }
+ fs.fsWriteJSON('/skills/index.json', index);
+ localStorage.setItem(key, m.version);
+ if (n) this.terminal.print('📚 Course skills synced (' + n + ') — the AI can use them via `load_skills`.', 'success');
+ } catch (e) { /* VFS busy — retried on next load */ }
+ }
+
+ _track(type, detail) {
+ try {
+ const sid = localStorage.getItem('webx11_course_sid') || (Math.random().toString(36).slice(2) + Date.now().toString(36));
+ localStorage.setItem('webx11_course_sid', sid);
+ if (localStorage.getItem('webx11_course_telemetry') === 'off') return;
+ fetch('/spaces/newos4/api/course/event.php', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, keepalive: true,
+ body: JSON.stringify({ sid, type, detail, version: (this._manifest && this._manifest.version) || '' })
+ }).catch(() => {});
+ } catch (e) {}
+ }
+}
+
+if (typeof window.webx11 !== 'undefined' && window.webx11) {
+ webx11.moduleManager.registerModule(new CourseModule(), 'https://webx11.duckdns.org/spaces/newos4/modules/ext/course.js');
+}