dante-watch 0.1.16 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  ```
6
6
  npx dante-watch # open a session: talk to Dante in German, English or Spanish
7
+ npm i -g dante-watch && dante # install once, then summon him with one word (careful: `npx dante` is another package)
7
8
  npx dante-watch --version # which version runs
8
9
  npx dante-watch --ssh root@host --key ~/.ssh/id # check a server you own, once
9
10
  ```
@@ -152,6 +153,26 @@ scary report — it is a fast, grounded check you can run on your own box and be
152
153
  - **Check me against another AI.** If Dante tells you something is open, verify it against an
153
154
  independent model. If that check disagrees, write us: info@nm-streetwear.space.
154
155
 
156
+ ## How it got built (and why we tell you)
157
+
158
+ We use AI. All the way, on purpose. We have no investors and no team, and we chose to go after a field where funded companies are
159
+ already running, so the only fair fight was speed - with proof.
160
+
161
+ In about three days (first commit 25 Oct 2026):
162
+
163
+ - **7,600 lines** of Dante across 77 files, plus **2,800 lines of tests** - 289 of them, green on macOS, Linux and a real Windows Server.
164
+ - **259,000 checked sentences** in German, English and Spanish, so he understands what you type - the way a person types it.
165
+ - **1,111 secret formats** (cloud keys, tokens, private keys) he looks for in your code, and **86 systems** he can tell apart.
166
+ - One real run of Dante on a real Windows Server, with answers typed like a person, on every change.
167
+
168
+ A normal team at a normal pace needs, on our own conservative assumptions - 75 finished and tested lines per developer-day, 300 labelled
169
+ sentences per hour, half an hour per secret format - roughly **320 person-days**: a person-year and a half. In three calendar days that
170
+ is a factor of about **100**. This is our estimate; the assumptions are written down so you can change them and do the sum yourself.
171
+
172
+ What the AI does not get to decide: every claim Dante makes has to show the real line it came from, "safe" is only said with proof,
173
+ and nothing changes on your machine without your *yes*. The tests, the equality checks against the real `find`/`grep`, and an opponent
174
+ that attacks Dante with hostile input are there to catch exactly the mistakes a fast machine makes. If you find one anyway, tell us.
175
+
155
176
  ## Credits
156
177
 
157
178
  Dante is the pilgrim who walked through Hell and came back with the map - companion to
@@ -163,9 +184,7 @@ Where the knowledge comes from, and what was *not* taken:
163
184
  was learned from the public documentation and check lists of PowerSploit/PowerUp, SharpUp (GhostPack), WinPEAS and HardeningKitty,
164
185
  and from Microsoft's own documentation of `icacls`, `schtasks` and the registry. We read what they look for; **we copied no code**.
165
186
  - **Technique names** in the explanation cards follow MITRE ATT&CK where we are sure of the id.
166
- - The written code was produced with AI help (Claude by Anthropic) under nm's direction and checked by tests; the German, English and
167
- Spanish training lines of the language library were generated with DeepSeek and filtered against hand-written test sentences.
168
- Every claim Dante makes still has to show a real line - that is the part no model gets to write.
187
+ - - How the code and the library were made, and how fast: see **How it got built** above.
169
188
  - Runs on [Node.js](https://nodejs.org) and your system's own `ssh`, `git` and `curl`; nothing else is installed.
170
189
 
171
190
  MIT License
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "dante-watch",
3
- "version": "0.1.16",
3
+ "version": "0.1.19",
4
4
  "description": "Dante watches your own server and never lies to itself: every verdict carries the real line it came from.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "dante-watch": "src/cli.mjs"
7
+ "dante-watch": "src/cli.mjs",
8
+ "dante": "src/cli.mjs"
8
9
  },
9
10
  "files": [
10
11
  "src",
package/src/bib/ear.mjs CHANGED
@@ -20,7 +20,7 @@ export const bibDate = () => load().date;
20
20
  // einig - act only when both ears are sure on their own and say the same; else ask back
21
21
  // kombi - the combiner's joint probability decides
22
22
  // The ranked list a human sees ("Meinst du ...?") always comes from the joint probability.
23
- // Measured per ear (25.10.): ohr bayes 131/138 · 1 wrong · einig 130 · 0 wrong · kombi 131 · 3 wrong
23
+ // Measured per ear on the held-out sentences: ohr bayes 131/138 · 1 wrong · einig 130 · 0 wrong · kombi 131 · 3 wrong
24
24
  // janein bayes 30/33 · einig 30/33 · kombi 33/33 (it can never say yes)
25
25
  const WAYS = { ohr: "einig", janein: "kombi", system: "einig", muster: "einig", wissen: "einig" };
26
26
  export const setWay = (w) => { WAYS.ohr = WAYS.janein = WAYS.system = WAYS.muster = WAYS.wissen = w; };
@@ -10,7 +10,7 @@ const SHARP = 2; // measured on the held-out set: 1 -> too shy, ≥2.5 ->
10
10
  export const SURE = 0.6; // below this the router asks instead of acting
11
11
  // Risk-weighted: an intent that only ANSWERS (a question, small talk, a greeting) may speak from
12
12
  // SURE_ANSWER on - a wrong answer is wrong text, never a deed. Actions keep SURE.
13
- // Measured (25.10.): 135 of 146 question misses had the right intent on top, only under 60 %.
13
+ // Measured on the held-out sentences: 135 of 146 question misses had the right intent on top, only under 60 %.
14
14
  export const ANSWER_ONLY = new Set(["frage_luecke", "frage_urteil", "frage_gehackt", "frage_dante", "smalltalk", "gruss", "hilfe", "unklar"]);
15
15
  export let SURE_ANSWER = Number(process.env.DANTE_SURE_ANSWER || 0.4);
16
16
  export const sureFor = (intent) => (ANSWER_ONLY.has(intent) ? SURE_ANSWER : SURE);
package/src/me/conv.mjs CHANGED
@@ -49,3 +49,18 @@ export const FALLBACK = {
49
49
  long: { de: "Das ist viel auf einmal. Sag es mir in einem Satz, was ich tun soll — oder „hilfe“.", en: "That is a lot at once. Tell me in one sentence what I should do — or “help”.", es: "Es mucho de golpe. Dime en una frase qué debo hacer — o «ayuda»." },
50
50
  again: { de: "Ich habe dich nicht verstanden, und raten bringt uns nicht weiter. Sag es anders, oder „hilfe“.", en: "I did not understand you, and guessing gets us nowhere. Put it another way, or “help”.", es: "No te he entendido y adivinar no ayuda. Dilo de otra forma, o «ayuda»." },
51
51
  };
52
+
53
+ // "wie mache ich den Schritt?" - the person means the open step of the last check, not a new check.
54
+ export const wantsHowFix = (line) => W("wie (?:mach(?:e|t|st)?|erledige|setze|führe|fuehre) ich (?:den|das|die|es|dies\\w*)|wie (?:behebe|beheb|löse|loese|schließe|schliesse|fixe|repariere) ich|wie (?:kann|soll|muss) ich (?:das|den|es|die) (?:beheben|lösen|loesen|schließen|schliessen|machen|beseitigen)|was (?:muss|soll|kann) ich (?:jetzt |nun )?(?:tun|machen)|was (?:nun|jetzt) tun|how (?:do|can|should) i (?:do|fix|close|solve|resolve|handle) (?:that|this|it|the step)|how to (?:fix|close|solve) (?:that|this|it)|what (?:should|do|can|must) i do(?: now| about (?:that|this|it))?|c[oó]mo (?:lo |la )?(?:arreglo|soluciono|resuelvo|hago|cierro)|qu[eé] (?:hago|debo hacer|tengo que hacer)").test(line);
55
+
56
+ // "prüfe die 4" after a numbered list was shown: the number belongs to THAT list. -> the number, or null
57
+ export function menuPick(line, size) {
58
+ const t = String(line).trim();
59
+ const m = /(?:^|\s)(?:pr[uü]f\w*|check|revisa|nimm|nehme?|take|mach|nummer|nr\.?|number|n[uú]mero|option|die|den|the|el|la|#)\s*(?:die|den|das|nummer|nr\.?|number|the|el|la|n[uú]mero|#)?\s*#?(\d{1,2})\b/i.exec(t);
60
+ const n = m ? Number(m[1]) : null;
61
+ return n && n >= 1 && n <= size && !/@|\./.test(t) ? n : null; // an address or a version number is not a menu pick
62
+ }
63
+ export const HOWFIX = {
64
+ head: { de: "Zum offenen Schritt, ohne etwas zu ändern:", en: "About the open step, without changing anything:", es: "Sobre el paso abierto, sin cambiar nada:" },
65
+ tail: { de: "Ich mache nichts, bevor du es willst. Sag „fix“, dann zeige ich den Plan noch einmal und frage nach deinem Ja.", en: "I do nothing until you want it. Say “fix” and I show the plan again and ask for your yes.", es: "No hago nada hasta que lo quieras. Di «fix» y te muestro el plan otra vez y pido tu sí." },
66
+ };
package/src/me/pick.mjs CHANGED
@@ -27,15 +27,67 @@ export function options({ servers = [], hints = [] }) {
27
27
  if (!servers.length) for (const h of hints.slice(0, 6)) out.push({ kind: "hint", host: h });
28
28
  return out;
29
29
  }
30
+ // "der zweite", "the last one", "el tercero": the place in a list, said in words. Cardinals ("drei") only count in a short answer
31
+ // to "Nummer oder Name"; ordinals count anywhere in a line. Two different places in one line ("die drei, nein die zwei") are no answer.
32
+ const ORD = [[/^(erst(e|en|er|es)?|first|primer[oa]?|1st)$/, 1], [/^(zweit(e|en|er|es)?|second|segund[oa]|2nd)$/, 2], [/^(dritt(e|en|er|es)?|third|tercer[oa]?|3rd)$/, 3], [/^(viert(e|en|er|es)?|fourth|cuart[oa]|4th)$/, 4], [/^(f[uü]nft(e|en|er|es)?|fuenft(e|en|er|es)?|fifth|quint[oa]|5th)$/, 5]];
33
+ const LAST = /^(letzt(e|en|er|es)?|last|[uú]ltim[oa])$/;
34
+ const CARD = { eins: 1, one: 1, uno: 1, zwei: 2, two: 2, dos: 2, drei: 3, three: 3, tres: 3, vier: 4, four: 4, cuatro: 4, "fünf": 5, fuenf: 5, five: 5, cinco: 5 };
35
+ export function placeIn(text, size, { cardinals = false } = {}) {
36
+ const found = new Set();
37
+ for (const w of String(text).toLowerCase().split(/[^\p{L}\d]+/u).filter(Boolean)) {
38
+ const o = ORD.find(([re]) => re.test(w));
39
+ if (o) found.add(o[1]); else if (LAST.test(w)) found.add(size); else if (cardinals && CARD[w]) found.add(CARD[w]);
40
+ }
41
+ const [n] = found;
42
+ return found.size === 1 && n >= 1 && n <= size ? n : null;
43
+ }
44
+ const esc = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
45
+ const hostPart = (t) => String(t || "").replace(/^[^@]*@/, "");
46
+ // a server named somewhere in the line: its alias or its address as a whole word ("nimm db", "203.0.113.12 halt")
47
+ const namedIn = (a0, opts) => {
48
+ const a = a0.replace(/[.,;:!?]+(?=\s|$)/g, " "); // "check db." - the full stop ends the sentence, it is not part of a host name
49
+ const hits = opts.filter((o) => o.kind === "server" || o.kind === "hint").filter((o) => [o.alias, hostPart(o.target), o.host].some((x) => x && x.length >= 2 && new RegExp(`(^|[^\\w.-])${esc(x.toLowerCase())}($|[^\\w.-])`).test(a)));
50
+ return hits.length === 1 ? hits[0] : null;
51
+ };
52
+
30
53
  /** a number, "mac"/"hier", "alle", or part of a server's name or address. Several matches → { kind: "many", list }. */
31
54
  export function choose(answer, opts) {
32
- const a = String(answer || "").trim().toLowerCase();
55
+ const a = String(answer || "").trim().toLowerCase().replace(/[\s!?.,;:)]+$/, ""); // "3!!", "2." are a number
33
56
  if (!a) return null;
34
57
  if (/^\d{1,3}$/.test(a)) return opts[Number(a) - 1] || null;
35
58
  if (/^(alle|alles|all|todos|todo|everything|jeden)$/.test(a)) return opts.find((o) => o.kind === "all") || null;
36
59
  if (LOCAL_RE.test(a) || /^(dieser|diesen|este|this)\b/.test(a)) return opts[0];
37
60
  const hits = opts.filter((o) => o.kind === "server" || o.kind === "hint").filter((o) => [o.alias, o.target, o.host].some((x) => x && x.toLowerCase().includes(a)));
38
- return hits.length === 1 ? hits[0] : hits.length > 1 ? { kind: "many", list: hits } : null;
61
+ if (hits.length) return hits.length === 1 ? hits[0] : { kind: "many", list: hits };
62
+ const named = namedIn(a, opts);
63
+ if (named) return named;
64
+ const n = placeIn(a, opts.length, { cardinals: a.split(/\s+/).length <= 4 });
65
+ return n ? opts[n - 1] : null;
66
+ }
67
+
68
+ // "mach mal bei dem zweiten", "nimm db", "the last one" after a list of machines: which one of the list? Only for a line that is about
69
+ // a machine (short, or with a machine word or a check verb), never for "der erste Schritt" or "the second finding".
70
+ const MACHINE = /server|servidor|rechner|maschine|machine|kiste|box|host|equipo|m[aá]quina|computer|ordenador|nimm|nehm|take|toma|coge|pr[uü]f|check|revisa|mira|schau|mach|haz|do\b/i;
71
+ const NOT_A_MACHINE = /schritt|step|paso|fund|befund|finding|hallazgo|punkt|point|punto|frage|question|pregunta|wort|word|palabra|befehl|command|comando|antwort|answer|respuesta|satz|sentence|frase|liste?\b|list\b/i;
72
+ const OTHER = /(^|[^\p{L}])(ander(e|en|er|es)?|other|otr[oa])(?![\p{L}])/u;
73
+ // A question about a machine ("welche ip hat db?", "cuál es, el segundo o el último") wants an answer, not a check.
74
+ const QUESTION = /(^|[^\p{L}])(cu[aá]l|welche\w*|which|qu[eé]|what|was|wo|where|d[oó]nde|warum|wieso|why|por qu[eé]|is there|gibt es|hay)(?![\p{L}])/u;
75
+ // a place counts with its article ("den zweiten", "the last one", "el otro"), so "revisa este equipo primero" (first = beforehand) does not
76
+ const ART = "(?:der|die|den|dem|das|the|el|la|lo|los|las)";
77
+ const PLACE_WORD = /^(erst|zweit|dritt|viert|f[uü]nft|fuenft|letzt)(e|en|er|es)?$|^(first|second|third|fourth|fifth|last)$|^(primer|segund|tercer|cuart|quint|[uú]ltim)[oa]?$/;
78
+ const withArticle = (l) => l.split(/[^\p{L}\d]+/u).filter(Boolean).some((w, i, ws) => PLACE_WORD.test(w) && i > 0 && new RegExp(`^${ART}$`).test(ws[i - 1]));
79
+ export function pickInLine(line, choices, current = null) {
80
+ const l = String(line || "").toLowerCase().trim(), words = l.split(/\s+/).length;
81
+ if (!choices || !choices.length || NOT_A_MACHINE.test(l) || QUESTION.test(l) || (l.includes("?") && words > 4)) return null;
82
+ if (!(words <= 6 || MACHINE.test(l))) return null;
83
+ // "und der andere": of two machines, the one that is not the current one
84
+ const servers = choices.filter((c) => c.kind === "server");
85
+ if (OTHER.test(l) && new RegExp(`${ART}\\s+(ander|other|otr)`).test(l) && current && servers.length === 2 && servers.some((c) => c.target === current)) return servers.find((c) => c.target !== current);
86
+ // a name counts with a check verb or alone ("nimm db", "check db", "db")
87
+ const named = namedIn(l, choices);
88
+ if (named && named.kind === "server" && (words <= 3 || /nimm|nehm|take|toma|coge|pr[uü]f|check|revisa|mira|schau|scan/.test(l))) return named;
89
+ const n = (words <= 2 || withArticle(l)) ? placeIn(l, choices.length) : null;
90
+ return n && choices[n - 1].kind !== "hint" ? choices[n - 1] : null;
39
91
  }
40
92
 
41
93
  const label = (M, o) => (o.kind === "local" ? M.pickLocal : o.kind === "all" ? M.pickAll(o.count) : o.kind === "server" ? M.pickServer(o) : M.pickHint(o.host));
package/src/session.mjs CHANGED
@@ -14,7 +14,7 @@ import { activeBattery, checkById } from "./checks/battery.mjs";
14
14
  import { demo } from "./checks/demo.mjs";
15
15
  import { run, firstLine } from "./core/sh.mjs";
16
16
  import { slots } from "./bib/router.mjs";
17
- import { newConv, note, understood, fallback, FALLBACK, wantsRecap, recap, wantsCodeProof, CODE_PROOF } from "./me/conv.mjs";
17
+ import { newConv, note, understood, fallback, FALLBACK, wantsRecap, recap, wantsCodeProof, CODE_PROOF, wantsHowFix, menuPick, HOWFIX } from "./me/conv.mjs";
18
18
  import { hearMain, hearConsent, hearSystem, hearMuster, hearWissen, hearDomain, langOf, SETUP_WORDS } from "./bib/ear.mjs";
19
19
  import { cardsOf, findCard, answer as knowAnswer } from "./pack/know.mjs";
20
20
  import { FAMILY_OF } from "./bib/system.mjs";
@@ -30,7 +30,7 @@ import * as schedule from "./me/schedule.mjs";
30
30
  import * as deepSchedule from "./me/schedule-deep.mjs";
31
31
  import { deep as deepRun } from "./me/deep.mjs";
32
32
  import { holder } from "./me/lock.mjs";
33
- import { wantsLocal, wantsServerPick, badTarget, options as pickOptions, pickFlow } from "./me/pick.mjs";
33
+ import { wantsLocal, wantsServerPick, badTarget, options as pickOptions, pickFlow, pickInLine } from "./me/pick.mjs";
34
34
  import { pool } from "./me/pool.mjs";
35
35
  import { wantsServerList, wantsHealth, wantsFind, wantsStatus, wantsMore, wantsServersCheck, serversFlow, findFlow } from "./me/servers.mjs";
36
36
  import { wantsSysInfo, wantsGoingOn, wantsAllSystems } from "./me/sysq.mjs";
@@ -41,7 +41,7 @@ import { script as sysScript, nativeOutput as sysNative } from "./me/sysid.mjs";
41
41
  import { report as sysReport } from "./me/sysreport.mjs";
42
42
  import { LOCAL_RE } from "./me/pick.mjs";
43
43
  // words that open a yes, and words that spoil one; words that ask for an explanation instead of answering
44
- const LEADING_YES = /^\s*["'¡]*(?:ja+|jap|jo|jawohl|klar|gern(?:e)?|na klar|okay|ok|yes+|yep|yeah|yup|sure|please|of course|go ahead|go on|s[ií]+|sip|claro|vale|adelante|dale|por favor|bitte|mach(?: ruhig)?|los|schau|look)(?![\p{L}])/iu;
44
+ const LEADING_YES = /^\s*["'¡]*(?:ja+|j|jap|jo|jawohl|meinetwegen|von mir aus|na gut|klar|gern(?:e)?|na klar|okay|ok|yes+|yep|yeah|yup|sure|please|of course|go ahead|go on|s[ií]+|sip|claro|vale|adelante|dale|por favor|bitte|mach(?: ruhig)?|los|schau|look)(?![\p{L}])/iu;
45
45
  const NEGATION = /(?<![\p{L}])(?:nein|nee|nicht|nie|no|nope|don'?t|do not|never|stop|halt|nunca|ni|aber nicht|lieber nicht)(?![\p{L}])/iu;
46
46
  const LEADING_NO = /^\s*["'¡]*(?:nein|nee|ne|nö|nope|no|nicht|lieber nicht|nada|nunca)(?![\p{L}])/iu;
47
47
  const ASKS_WHAT = /\?|(?<![\p{L}])(?:was (?:ist|heißt|bedeutet|macht)|erkl[aä]r\p{L}*|what(?:'s| is| does)|explain|qu[eé] (?:es|significa)|no s[eé] qu[eé]|ich wei(?:ß|ss) nicht was)(?![\p{L}])/iu;
@@ -210,6 +210,8 @@ export async function session() {
210
210
  const a = await ask(check.id, t("run"), { plan: r.fix, rl });
211
211
  if (a === "no") { say(" " + C("2", t("nothing"))); return; }
212
212
  if (a === "always") { grantAutomode(check.id); }
213
+ // DANTE_FIX_DRY=1 (tests, the lab): the plan is shown and agreed, nothing runs
214
+ if (process.env.DANTE_FIX_DRY) { for (const s of r.fix.steps) say(" " + C("2", "(dry) " + tx(s.desc) + " $ " + s.script)); return; }
213
215
  for (const s of r.fix.steps) {
214
216
  const o = await waitFor(tx(s.desc), job({ kind: "exec", scripts: [s.script], timeout: 120000 }, target$()));
215
217
  const res = o.reached ? o.outs[0] : { ok: false, out: "", err: o.line };
@@ -456,18 +458,25 @@ export async function session() {
456
458
  if (wordsServer && u && (u.servers || []).length) return pickMove({ sys: mode });
457
459
  return sysOne(null, "localhost", mode);
458
460
  };
459
- const pickMove = async (opts = {}) => {
461
+ const serverChoices = () => (((readUser() || {}).servers) || []).map((s) => ({ kind: "server", alias: s.alias, target: s.target, key: s.key || null, os: s.os || "" }));
462
+ let lastMenu = null, turnNo = 0; // the numbered list that was last on screen, and when: "prüfe die 4" belongs to it
463
+ const applyPick = async (o, opts = {}) => {
460
464
  const u = readUser();
461
- const choices = pickOptions({ servers: (u && u.servers) || [], hints: knownHosts(homedir(), ((u && u.servers) || []).map((s) => s.alias)) });
462
- const o = await pickFlow(io, choices);
463
- if (!o) { say(" " + C("2", mt().pickNone)); return; }
464
465
  if (o.kind === "all") return opts.sys ? sysAll((u && u.servers) || [], opts.sys) : fleetMove((u && u.servers) || []);
465
466
  if (o.kind === "local") { target = null; configureTarget(null); say(" " + C("2", t("target") + "localhost")); return opts.sys ? sysOne(null, "localhost", opts.sys) : runChecks(false); }
466
467
  if (o.key) key = o.key.replace(/^~(?=\/)/, homedir());
467
468
  target = o.target; configureTarget({ host: target, key }); say(" " + C("2", t("target")) + targetLabel());
468
469
  return opts.sys ? sysOne(target$(), targetLabel(), opts.sys) : runChecks(false);
469
470
  };
470
- // What the Zentrale may order Dante to do on its own: only answers and looks. Anything that changes something still asks, as everywhere.
471
+ const pickMove = async (opts = {}) => {
472
+ const u = readUser();
473
+ const choices = pickOptions({ servers: (u && u.servers) || [], hints: knownHosts(homedir(), ((u && u.servers) || []).map((s) => s.alias)) });
474
+ lastMenu = { choices, opts, turn: turnNo };
475
+ const o = await pickFlow(io, choices);
476
+ if (!o) { say(" " + C("2", mt().pickNone)); return; }
477
+ return applyPick(o, opts);
478
+ };
479
+ // What such a helper may order Dante to do on its own: only answers and looks. Anything that changes something still asks, as everywhere.
471
480
  const viaZentrale = async (z, line) => {
472
481
  const ok = new Set(["gruss", "hilfe", "smalltalk", "karte", "frage_dante", "frage_luecke", "frage_urteil", "frage_gehackt", "pruef"]);
473
482
  if (z.ear === "muster") { await musterMove(z.intent); return true; }
@@ -532,7 +541,7 @@ export async function session() {
532
541
 
533
542
  // The call home of the test builds (src/lab is not in the published package: then this stays null and changes nothing).
534
543
  let turn = null;
535
- const closeTurn = () => { if (turn) { note(conv, turn.line, turn.intent, turn.route); if (turn.route !== "unclear") understood(conv); if (lab) lab.log(turn); } turn = null; };
544
+ const closeTurn = () => { if (turn) { note(conv, turn.line, turn.intent, turn.route); if (turn.route !== "unclear") understood(conv); if (lab) lab.log({ ...turn, ctx: { menuAge: lastMenu ? turnNo - lastMenu.turn : null, open: last.filter(({ r }) => r.verdict !== SAFE).length, topic: lastTopic, target: !!target, asked: conv.asked } }); } turn = null; };
536
545
  let pending = null; // the guess Dante asked back about; a plain "ja" takes it
537
546
  const conv = newConv(); // what this conversation remembers (memory only, never written)
538
547
  for (;;) {
@@ -560,7 +569,14 @@ export async function session() {
560
569
  // (user.md, then known_hosts) instead of asking blind.
561
570
  let forceLocal = false;
562
571
  const prevTopic = lastTopic; lastTopic = null; // what the last answer was about, for "welche noch?"
563
- if (!exact && wantsRecap(line)) heard.intent = "recap";
572
+ turnNo++;
573
+ let menuN = null, linePick = null;
574
+ const menuNow = lastMenu && turnNo - lastMenu.turn <= 8 ? lastMenu : null;
575
+ if (!exact && menuNow && (menuN = menuPick(line, menuNow.choices.length)) && menuNow.choices[menuN - 1].kind !== "hint") heard.intent = "menupick";
576
+ // "mach mal bei dem zweiten", "nimm db", "und der andere?": a machine from the list on screen, or from the known servers in the order he shows them
577
+ else if (!exact && !ziel && (linePick = pickInLine(line, menuNow ? menuNow.choices : serverChoices(), target))) heard.intent = "linepick";
578
+ else if (!exact && last.some(({ r }) => r.verdict !== SAFE && r.fix) && wantsHowFix(line)) heard.intent = "howfix";
579
+ else if (!exact && wantsRecap(line)) heard.intent = "recap";
564
580
  else if (!exact && wantsCodeProof(line)) heard.intent = "codebeweis";
565
581
  else if (!exact && prevTopic === "servers" && wantsMore(line)) heard.intent = "servermore";
566
582
  else if (!exact && wantsServersCheck(line, !!ziel)) heard.intent = "serverscheck";
@@ -583,6 +599,16 @@ export async function session() {
583
599
  turn = { line, lang: lang(), route: exact ? "cmd" : heard.intent.startsWith("server") || /^(sysinfo|waslos|systemealle|mitm|mitmauto|keyfind)$/.test(heard.intent) ? "rule" : "main", intent: heard.intent, p: heard.ranked && heard.ranked[0] ? Number(heard.ranked[0].p.toFixed(2)) : 1 };
584
600
  switch (heard.intent) {
585
601
  case "raus": say(" " + C("2", t("bye"))); footerStop(); rl.close(); return;
602
+ case "menupick": await applyPick(lastMenu.choices[menuN - 1], lastMenu.opts); break;
603
+ case "linepick": await applyPick(linePick, menuNow ? menuNow.opts : {}); break;
604
+ case "howfix": {
605
+ say(" " + C("2", HOWFIX.head[lang()]));
606
+ for (const { r } of last.filter(({ r }) => r.verdict !== SAFE && r.fix).slice(0, 3)) {
607
+ say(" " + C("1", tx(r.title)) + " - " + tx(r.fix.why || ""));
608
+ r.fix.steps.forEach((st, i) => say(" " + (i + 1) + ") " + tx(st.desc) + " " + C("2", "$ " + st.script)));
609
+ }
610
+ say(" " + C("33", HOWFIX.tail[lang()])); break;
611
+ }
586
612
  case "recap": say(recap(conv, lang()).map((l) => " " + l).join("\n")); break;
587
613
  case "codebeweis": say(CODE_PROOF[lang()].map((l) => " " + l).join("\n")); break;
588
614
  case "gruss": say(" " + pick(t("hello"))); break;
@@ -655,7 +681,7 @@ export async function session() {
655
681
  warmDomain();
656
682
  const dom = hearDomain(line);
657
683
  if (dom) { turn = { ...turn, route: "domain:" + dom.ear, intent: dom.intent }; }
658
- // The Zentrale (test builds only): a line nobody understood is sorted into a template by the central brain, and Dante acts on it.
684
+ // Optional hook for test builds (absent in the published package): a line nobody understood can be sorted into a template by a helper service.
659
685
  const zen = !dom && lab ? await waitFor("…", lab.ask({ line, lang: lang(), heard: { intent: heard.intent, ranked: (heard.ranked || []).slice(0, 3) }, version: VERSION })) : null;
660
686
  if (zen && (await viaZentrale(zen, line))) { turn = { ...turn, route: "zentrale", intent: zen.intent, ear: zen.ear }; break; }
661
687
  if (dom) { if (dom.ear === "muster") await musterMove(dom.intent); else if (dom.ear === "system") await systemMove(dom.intent); else knowMove(dom.intent, line); break; }
package/src/ticker.mjs CHANGED
@@ -49,7 +49,7 @@ export function age(now = Date.now()) {
49
49
  return Math.max(0, Math.floor((now - Date.parse(knowledge + "T00:00:00Z")) / 864e5));
50
50
  }
51
51
 
52
- // "bib 25.10. · 0d" - the date of the knowledge and how old it is; "bib 20d · !" once it is stale.
52
+ // "bib 25.10. · 0d" (example) - the date of the knowledge and how old it is; "bib 20d · !" once it is stale.
53
53
  export function clockText() {
54
54
  const days = age(), v = VERSION ? ` · v${VERSION}` : "";
55
55
  return (days > STALE ? `bib ${days}d · !` : `bib ${knowledge.slice(8, 10)}.${knowledge.slice(5, 7)}. · ${days}d`) + v;