Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/integrations/HERMES.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,23 @@ Garde-fous : seuls `.pdf`, `.md`, `.txt` sous ce dossier ; `knowledge-private/`,
N’y mets pas de documents d’entreprise internes, de données personnelles d’autrui ni de secrets.

Le serveur est lancé depuis ce dépôt : garde une branche qui contient `mcp/` (par exemple `main-public`).

## Coach du matin (tâche planifiée Hermes, sans modèle)

`scripts/morning-coach.ts` construit chaque matin un message à partir du résumé : série, révisions dues, question de
rappel (difficulté principale), mot d’anglais du jour, réplique de jeu de rôle, compte à rebours d’examen. Aucun modèle
d’IA, aucun réseau : le message est écrit dans `coach-du-jour.md` à côté du résumé et affiché en notification macOS.

```bash
cat > ~/.hermes/scripts/elos-morning-coach.sh <<'SH'
#!/bin/bash
cd "$HOME/Desktop/Developer/daily-english-mission" || exit 1
ELOS_STATE_FILE="$HOME/ELOS-Hermes/elos-state.json" exec "$(command -v node)" --experimental-strip-types --no-warnings \
--import ./tests/register-loader.mjs scripts/morning-coach.ts --notify
SH
chmod +x ~/.hermes/scripts/elos-morning-coach.sh
hermes cron create "0 8 * * *" --name "ELOS coach du matin" --script elos-morning-coach.sh --no-agent --deliver local
```

Avec une messagerie configurée dans Hermes (`hermes gateway setup`), remplace `--deliver local` par `telegram`,
`signal`… Le contenu (vocabulaire, jeux de rôle, intitulés de notions) est générique ; aucun passage de cours n’y figure.
Comment on lines +80 to +81

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Warn before forwarding snapshot-derived labels

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-agent mode delivers script stdout verbatim, so replacing local with 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 👍 / 👎.

21 changes: 21 additions & 0 deletions scripts/morning-coach.ts
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());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle nested snapshot corruption in the fallback

The surrounding try handles only reading and parsing, while parseLearnerSnapshot accepts structurally incomplete same-version values such as dailyEnglish: {} and studio: {}. Passing that value here makes the builder throw on fields such as cards.length, so the scheduled job exits without writing or delivering the intended missing-snapshot fallback. Include message construction in the recovery path or validate the complete nested schema before returning it.

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);
54 changes: 54 additions & 0 deletions src/modules/learner-snapshot/coach.ts
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject prior-day snapshots before reporting today's plan

When ELOS was last opened the previous evening, the snapshot can be less than 24 hours old and therefore trigger no warning, but snapshot.today, dueToday, dueWords, and daysLeft still describe yesterday. The 08:00 job then labels yesterday's workload as today's, repeats the previous word, and reports a stale exam countdown beneath a header generated from the current date. Compare snapshot.today with the current local calendar day and require a refresh or avoid presenting its date-dependent fields as current.

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");
}
38 changes: 38 additions & 0 deletions tests/unit/learner-snapshot/coach.test.ts
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)));
});
Loading