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 |check|hint|progress|reset|ask |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 `', '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'); } if (localStorage.getItem('webx11_course_skills_ver') !== m.version) this._installSkills().catch(() => {}); 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 | 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 `.', 'success'); } if (sub === 'open') { if (!args[1]) return this.terminal.print('Usage: course open ', '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 ` 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 ', '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 ` 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 ` 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 `', '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'); }