Repository navigation
feat(elos): morning coach from the learner snapshot, scheduled by Hermes #23
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| // Prints the ELOS morning coach message from the learner snapshot (ELOS_STATE_FILE), | ||
| // writes it next to the snapshot (coach-du-jour.md) and, with --notify, shows a macOS | ||
| // notification. No model, no network. Scheduled by Hermes: see docs/integrations/HERMES.md. | ||
| import { execFileSync } from "node:child_process"; | ||
| import { readFileSync, writeFileSync } from "node:fs"; | ||
| import path from "node:path"; | ||
| import { buildMorningMessage } from "../src/modules/learner-snapshot/coach.ts"; | ||
| import { parseLearnerSnapshot, type LearnerSnapshot } from "../src/modules/learner-snapshot/core.ts"; | ||
|
|
||
| const file = process.env.ELOS_STATE_FILE ?? path.join(process.env.HOME ?? "", "ELOS-Hermes", "elos-state.json"); | ||
| let snapshot: LearnerSnapshot | null = null; | ||
| try { snapshot = parseLearnerSnapshot(readFileSync(file, "utf8")); } catch { snapshot = null; } | ||
| const message = buildMorningMessage(snapshot, new Date()); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The surrounding AGENTS.md reference: AGENTS.md:L30-L31 Useful? React with 👍 / 👎. |
||
| try { writeFileSync(path.join(path.dirname(file), "coach-du-jour.md"), message + "\n", { mode: 0o600 }); } catch { /* folder missing: stdout only */ } | ||
| if (process.argv.includes("--notify") && process.platform === "darwin") { | ||
| const summary = message.split("\n").filter((line) => /^(📚|❓|🎯)/u.test(line)).join(" ").slice(0, 220) || "Ouvre ELOS pour ta séance du jour."; | ||
| try { | ||
| execFileSync("osascript", ["-e", "on run argv", "-e", "display notification (item 1 of argv) with title \"ELOS — séance du jour\"", "-e", "end run", summary]); | ||
| } catch { /* notifications disabled: the message is still printed */ } | ||
| } | ||
| console.log(message); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| import { VOCABULARY } from "../daily-english/vocabulary"; | ||
| import { ROLEPLAY_SCENARIOS } from "../daily-english/roleplay"; | ||
| import type { LearnerSnapshot } from "./core"; | ||
|
|
||
| /** | ||
| * Morning coach message built only from the learner snapshot and ELOS content, | ||
| * without any model: deterministic, free, nothing sent to an AI provider. | ||
| * Retrieval question first (recall before re-reading), then a short English | ||
| * practice and the exam countdown. | ||
| */ | ||
|
|
||
| const STALE_MS = 24 * 3_600_000; | ||
|
|
||
| function dayIndex(day: string): number { | ||
| return Math.floor(Date.parse(`${day}T00:00:00Z`) / 86_400_000); | ||
| } | ||
|
|
||
| export function buildMorningMessage(snapshot: LearnerSnapshot | null, now: Date): string { | ||
| const date = now.toLocaleDateString("fr-FR", { weekday: "long", day: "numeric", month: "long" }); | ||
| const lines = [`☀️ ELOS — ${date}`]; | ||
| if (!snapshot) { | ||
| lines.push("", "Je ne trouve pas ton résumé ELOS. Ouvre l’app (Données → Agent local) pour le partager."); | ||
| return lines.join("\n"); | ||
| } | ||
| const today = snapshot.today; | ||
| const index = dayIndex(today); | ||
| if (now.getTime() - Date.parse(snapshot.generatedAt) > STALE_MS) { | ||
|
Comment on lines
+25
to
+27
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When ELOS was last opened the previous evening, the snapshot can be less than 24 hours old and therefore trigger no warning, but Useful? React with 👍 / 👎. |
||
| lines.push("⚠️ Résumé de plus de 24 h : ouvre ELOS pour le mettre à jour."); | ||
| } | ||
| lines.push(`🔥 Série Daily English : ${snapshot.dailyEnglish.streak} jour${snapshot.dailyEnglish.streak > 1 ? "s" : ""} · ${snapshot.dailyEnglish.wordsMastered}/${snapshot.dailyEnglish.totalWords} mots maîtrisés`); | ||
|
|
||
| const cards = snapshot.studio.dueToday; | ||
| const words = snapshot.dailyEnglish.dueWords; | ||
| const workload = [cards.length ? `${cards.length} carte${cards.length > 1 ? "s" : ""} de cours` : null, words.length ? `${words.length} mot${words.length > 1 ? "s" : ""} d’anglais` : null].filter(Boolean); | ||
| lines.push("", workload.length ? `📚 À réviser aujourd’hui : ${workload.join(" · ")}` : "📚 Rien de dû aujourd’hui : profites-en pour une mission Daily English."); | ||
|
|
||
| const recall = snapshot.studio.difficulties[0]?.term ?? cards[index % Math.max(1, cards.length)]?.term ?? null; | ||
| if (recall) lines.push(`❓ Rappel actif : sans regarder, que désigne « ${recall} » ? Vérifie ensuite dans le Studio.`); | ||
|
|
||
| const word = VOCABULARY[((index % VOCABULARY.length) + VOCABULARY.length) % VOCABULARY.length]; | ||
| lines.push("", `🇬🇧 Mot du jour : ${word.word} (${word.translation})`, ` « ${word.example} »`); | ||
|
|
||
| const scenario = ROLEPLAY_SCENARIOS[((index % ROLEPLAY_SCENARIOS.length) + ROLEPLAY_SCENARIOS.length) % ROLEPLAY_SCENARIOS.length]; | ||
| const turn = scenario.turns[0]; | ||
| lines.push(`🗣️ À l’oral (${scenario.title}) : « ${turn.interlocutor} »`, ` ${turn.task}`); | ||
|
|
||
| const exam = snapshot.exams[0]; | ||
| if (exam) { | ||
| const advice = exam.daysLeft <= 1 ? "révision légère + flashcards" : exam.daysLeft <= 3 ? "examen blanc chronométré" : exam.daysLeft <= 7 ? "rappel actif sur le cours dans le Studio" : "révisions espacées régulières"; | ||
| lines.push("", `🎯 ${exam.course} dans ${exam.daysLeft} jour${exam.daysLeft > 1 ? "s" : ""} : ${advice}.`); | ||
| } | ||
| lines.push("", "https://daily-english-mission-ten.vercel.app/studio"); | ||
| return lines.join("\n"); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import assert from "node:assert/strict"; | ||
| import test from "node:test"; | ||
| import { buildMorningMessage } from "../../../src/modules/learner-snapshot/coach.ts"; | ||
| import type { LearnerSnapshot } from "../../../src/modules/learner-snapshot/core.ts"; | ||
|
|
||
| const base: LearnerSnapshot = { | ||
| format: "engineer-learning-os/learner-snapshot", version: 1, generatedAt: "2026-10-23T05:00:00.000Z", today: "2026-10-23", | ||
| dailyEnglish: { streak: 4, wordsSeen: 30, wordsMastered: 3, totalWords: 150, todayMission: null, dueWords: [{ word: "embrace", translation: "accepter", due: "2026-10-23" }] }, | ||
| studio: { scheduler: "CURRENT_V1", dueToday: [{ term: "TCP", source: "Cours" }, { term: "UDP", source: "Cours" }], next7Days: [], difficulties: [{ term: "clé étrangère", errors: 2, attempts: 3 }], schedulerReplay: { predictions: 0, enough: false, logLoss: {} } }, | ||
| exams: [{ course: "Base de données SQL", date: "2026-10-26", priority: "P1", daysLeft: 3 }], | ||
| privacy: { excludes: [] }, | ||
| }; | ||
|
|
||
| test("morning message: streak, workload, recall on the top difficulty, English practice, exam advice", () => { | ||
| const message = buildMorningMessage(base, new Date("2026-10-23T06:00:00Z")); | ||
| assert.match(message, /Série Daily English : 4 jours/); | ||
| assert.match(message, /2 cartes de cours · 1 mot d’anglais/); | ||
| assert.match(message, /que désigne « clé étrangère »/); | ||
| assert.match(message, /Mot du jour : /); | ||
| assert.match(message, /À l’oral \(/); | ||
| assert.match(message, /Base de données SQL dans 3 jours : examen blanc chronométré/); | ||
| assert.ok(!/⚠️/.test(message)); | ||
| }); | ||
|
|
||
| test("stale or missing snapshots are stated, nothing is invented", () => { | ||
| assert.match(buildMorningMessage(base, new Date("2026-10-25T08:00:00Z")), /plus de 24 h/); | ||
| const missing = buildMorningMessage(null, new Date()); | ||
| assert.match(missing, /Je ne trouve pas ton résumé ELOS/); | ||
| assert.ok(!/À réviser/.test(missing)); | ||
| }); | ||
|
|
||
| test("message is deterministic for a given day and rotates the word of the day", () => { | ||
| const now = new Date("2026-10-23T06:00:00Z"); | ||
| assert.equal(buildMorningMessage(base, now), buildMorningMessage(base, now)); | ||
| const tomorrow = buildMorningMessage({ ...base, today: "2026-10-24", generatedAt: "2026-10-24T05:00:00.000Z" }, new Date("2026-10-24T06:00:00Z")); | ||
| const word = (text: string) => text.match(/Mot du jour : (.+)/)![1]; | ||
| assert.notEqual(word(tomorrow), word(buildMorningMessage(base, now))); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For learners with imported personal course documents, the message is not entirely generic: it includes
studio.difficulties[].term, which is derived from those documents, and the exam course name. Hermes'--no-agentmode delivers script stdout verbatim, so replacinglocalwith Telegram or Signal forwards these labels to an external service despite the assurance here. Disclose the exact fields and obtain informed authorization, or redact snapshot-derived labels for external delivery.AGENTS.md reference: AGENTS.md:L14-L14
Useful? React with 👍 / 👎.