leetcode-capture-helper 0.0.0-stage → 0.1.1

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.
Files changed (48) hide show
  1. package/cli.js +92 -0
  2. package/config.js +25 -0
  3. package/framing.js +57 -0
  4. package/host.js +420 -0
  5. package/install.js +290 -0
  6. package/package.json +31 -4
  7. package/runtime/bundle.json +42 -0
  8. package/runtime/companion/capture-log.js +83 -0
  9. package/runtime/companion/codex-backend.js +337 -0
  10. package/runtime/companion/companion.js +2190 -0
  11. package/runtime/companion/mouse-input.js +154 -0
  12. package/runtime/companion/package.json +4 -0
  13. package/runtime/companion/panel-bridge.js +273 -0
  14. package/runtime/companion/settings-rpc.js +228 -0
  15. package/runtime/companion/settings.js +464 -0
  16. package/runtime/companion/terminal-format.js +330 -0
  17. package/runtime/companion/vault-summary.js +168 -0
  18. package/runtime/companion/virtual-terminal.js +138 -0
  19. package/runtime/extension/shared/anthropic-backend.js +193 -0
  20. package/runtime/extension/shared/catalog.js +415 -0
  21. package/runtime/extension/shared/code-ledger.js +333 -0
  22. package/runtime/extension/shared/context-budget.js +259 -0
  23. package/runtime/extension/shared/context-summary.js +271 -0
  24. package/runtime/extension/shared/conversation.js +81 -0
  25. package/runtime/extension/shared/event-hub.js +126 -0
  26. package/runtime/extension/shared/extension-settings.js +445 -0
  27. package/runtime/extension/shared/helper-launcher.js +248 -0
  28. package/runtime/extension/shared/local-backend.js +706 -0
  29. package/runtime/extension/shared/local-server.js +149 -0
  30. package/runtime/extension/shared/package.json +4 -0
  31. package/runtime/extension/shared/practice.js +228 -0
  32. package/runtime/extension/shared/problem-index.js +4128 -0
  33. package/runtime/extension/shared/profiles.js +106 -0
  34. package/runtime/extension/shared/providers.js +58 -0
  35. package/runtime/extension/shared/reply-guard.js +864 -0
  36. package/runtime/extension/shared/roadmap.js +1337 -0
  37. package/runtime/extension/shared/sanitize.js +68 -0
  38. package/runtime/extension/shared/solved.js +316 -0
  39. package/runtime/extension/shared/tutor-engine.js +369 -0
  40. package/runtime/extension/shared/tutor-prompt.js +108 -0
  41. package/runtime/extension/shared/tutor-service.js +520 -0
  42. package/runtime/relay-server/package.json +4 -0
  43. package/runtime/relay-server/panel-broker.js +797 -0
  44. package/runtime/relay-server/server.js +241 -0
  45. package/runtime/vault-tool/log-session.js +230 -0
  46. package/runtime/vault-tool/package.json +4 -0
  47. package/runtime/vault-tool/vault-notes.js +206 -0
  48. package/README.md +0 -3
@@ -0,0 +1,864 @@
1
+ // extension/shared/reply-guard.js - the post-check guard for the tutor's
2
+ // prompt rules, shared by every runtime: the extension's own engine
3
+ // (tutor-engine.js) and the companion (companion.js) run every tutor turn
4
+ // through `guardedSend`, so a reply that breaks a rule the code can check is
5
+ // fixed before the student sees it, the same way in both.
6
+ //
7
+ // A local model ignores some rules some of the time (a first hint that names
8
+ // the data structure, a "better way" answer with a whole solution, an em
9
+ // dash). The guard does not judge quality; it only enforces what is
10
+ // mechanically checkable, per kind of turn (see `classifyMessage`):
11
+ // - every turn: em dashes and spaced en dashes become commas, emoji go,
12
+ // Unicode arrows become ASCII (outside code), "repairs";
13
+ // - a hint or an "I'm stuck" turn is held back (the panel shows the typing
14
+ // dots, nothing streams) and checked whole; a broken one is re-asked
15
+ // once with the rule it broke, and if that fails too, the offending
16
+ // sentences are removed or a safe fallback stands in;
17
+ // - every other checked turn streams sentence by sentence, each sentence
18
+ // (and each code block, whole) checked before it is released; one that
19
+ // breaks a rule is dropped and never shown, so nothing on screen is ever
20
+ // replaced;
21
+ // - a turn the student asked to be given everything in (the full answer,
22
+ // code, a fix) streams word by word with only the repairs.
23
+ // The rejected reply never stays in the backend's history: the guard hands
24
+ // the final text to `replaceLastReply`.
25
+ //
26
+ // Pure JavaScript with no Node API (extension/shared.test.js checks that).
27
+
28
+ // ---- limits ------------------------------------------------------------------
29
+
30
+ // A first hint is "the smallest nudge, one question"; past these it is not.
31
+ export const FIRST_HINT_MAX_CHARS = 450;
32
+ export const LATER_HINT_MAX_CHARS = 900;
33
+ export const STUCK_MAX_CHARS = 450;
34
+ // A held reply this far past its limit is stopped early (the backend aborts
35
+ // the request), since it will be re-asked anyway: no point paying for the
36
+ // rest of a runaway local generation.
37
+ export const EARLY_STOP_FACTOR = 1.6;
38
+
39
+ // What stands in when a held reply and its one re-ask both break a rule and
40
+ // nothing safe is left of either.
41
+ export const FALLBACK_TEXT = {
42
+ hint: 'Pick a small input and trace your code on it by hand, one step at a time. What do you notice about what it does at each step?',
43
+ laterHint: 'Go back to the step from the last hint and trace just that part of your code on a small input. What happens there?',
44
+ stuck: 'What have you tried so far, and which step is unclear? I can give you a small hint if you like.',
45
+ other: 'I could not answer that within my tutoring rules. Could you ask it another way?',
46
+ };
47
+
48
+ // ---- turn kinds ----------------------------------------------------------------
49
+
50
+ const ANSWER_RE = /\b(?:full|complete|whole|entire|working)\s+(?:solution|answer|code|implementation)\b|\b(?:give|show|tell)\s+me\s+(?:the\s+)?(?:answer|solution)\b|\bjust\s+(?:give|show|tell)\s+me\b|\bsolve\s+it\s+for\s+me\b|\bi\s+give\s+up\b|\bsoluci\u00F3n\s+completa\b/i;
51
+ const HINT_RE = /\bhints?\b|\bnudge\b|\bclue\b|\bpista\b/i;
52
+ const STUCK_RE = /\b(?:i'?m|i\s+am|im)\s+(?:so\s+|really\s+|totally\s+|completely\s+)?stuck\b|\bno\s+idea\s+(?:how|where|what)\b|\bdon'?t\s+know\s+(?:how|where|what)\s+to\s+(?:start|begin|do|go)\b/i;
53
+ const ALTERNATIVES_RE = /\b(?:better|faster|other|another|different|alternative|more\s+efficient|cleaner|optimal)\s+(?:way|ways|approach|approaches|method|methods|solution|solutions|algorithm|algorithms)\b|\balternatives?\b|\boptimi[sz]e\b/i;
54
+ const CODE_REQUEST_RE = /\b(?:show|give|write|send|share|post)\s+(?:me\s+)?(?:the\s+|some\s+|working\s+)?(?:code|implementation|pseudo-?code)\b|\b(?:with|in|as)\s+(?:the\s+)?(?:code|pseudo-?code)\b|\bcode\s+(?:it|this|that)\b|\bimplement\s+(?:it|this|that)\b|\bwrite\s+(?:it|this|that)\s+(?:out|for\s+me)\b|\bpseudo-?code\b/i;
55
+ const FIX_REQUEST_RE = /\b(?:fix|repair|correct\s+(?:it|this|that|my)|how\s+(?:do|can|should|would)\s+i\s+(?:change|correct|make))\b/i;
56
+
57
+ // What kind of turn the student's own words ask for. `trigger` 'submit' is a
58
+ // Submit review; `hintsGiven` counts earlier hint turns on this problem.
59
+ // Returns { kind, hintNumber? } with kind one of 'submit', 'answer', 'hint',
60
+ // 'stuck', 'alternatives', 'chat'.
61
+ export function classifyMessage(text, { trigger = null, hintsGiven = 0 } = {}) {
62
+ if (trigger === 'submit') return { kind: 'submit' };
63
+ const value = typeof text === 'string' ? text : '';
64
+ if (ANSWER_RE.test(value)) return { kind: 'answer' };
65
+ if (HINT_RE.test(value)) return { kind: 'hint', hintNumber: hintsGiven + 1 };
66
+ const alternatives = ALTERNATIVES_RE.test(value);
67
+ if (alternatives) return { kind: CODE_REQUEST_RE.test(value) ? 'answer' : 'alternatives' };
68
+ if (CODE_REQUEST_RE.test(value) || FIX_REQUEST_RE.test(value)) return { kind: 'answer' };
69
+ if (STUCK_RE.test(value) && !value.includes('?')) return { kind: 'stuck' };
70
+ return { kind: 'chat' };
71
+ }
72
+
73
+ // ---- techniques ------------------------------------------------------------------
74
+
75
+ // Methods and data structures the tutor must not volunteer. A group counts as
76
+ // named once the student wrote any of its terms, its code shows one of its
77
+ // signals, or a reply the student already saw mentioned it.
78
+ const TECHNIQUES = [
79
+ { id: 'hash-map', terms: /\bhash\s*(?:map|table|maps|tables|ing)\b|\bhashmaps?\b|\bdictionar(?:y|ies)\b|\bdicts?\b|\blookup\s+table\b/i, signals: /\bdict\s*\(|\bdefaultdict\b|\bCounter\s*\(|=\s*\{\s*\}|\bHashMap\b|\bunordered_map\b|\bnew\s+Map\b|\bMap\s*<|\bmap\s*\[|\bmake\s*\(\s*map\b|\bdictionary\b|\bhash\b/i },
80
+ { id: 'hash-set', terms: /\bhash\s*sets?\b|\bhashsets?\b/i, signals: /\bset\s*\(|\bHashSet\b|\bunordered_set\b|\bnew\s+Set\b|\bSet\s*<|=\s*\{\s*\w[^:}]*\}/i },
81
+ { id: 'heap', terms: /\b(?:min-?|max-?)?heaps?\b|\bpriority\s+queues?\b/i, signals: /\bheapq\b|\bPriorityQueue\b|\bpriority_queue\b|\bheap\b/i },
82
+ { id: 'stack', terms: /\bmonotonic\s+(?:stack|queue)\b|\b(?:an?|explicit|own)\s+stack\b|\bstack-based\b|\buse\s+a\s+stack\b/i, signals: /\bstack\b/i },
83
+ { id: 'queue', terms: /\b(?:a|explicit)\s+queue\b|\bdeque\b/i, signals: /\bdeque\b|\bqueue\b|\bQueue\b/i },
84
+ { id: 'trie', terms: /\btries\b|\btrie\b|\bprefix\s+tree\b/i, signals: /\btrie\b/i },
85
+ { id: 'union-find', terms: /\bunion[\s-]*find\b|\bdisjoint[\s-]*set\b|\bdsu\b/i, signals: /\bunion\s*\(|\bfind\s*\(|\bparent\s*\[/i },
86
+ { id: 'tree-index', terms: /\bsegment\s+trees?\b|\bfenwick\b|\bbinary\s+indexed\s+trees?\b/i, signals: /\bsegment\b|\bfenwick\b|\bbit\s*\[/i },
87
+ { id: 'binary-search', terms: /\bbinary\s+search\b|\bbisect\w*\b/i, signals: /\bbisect\b|\bmid\b|\blo\s*\+\s*hi\b|\bleft\s*\+\s*right\b/i },
88
+ { id: 'two-pointers', terms: /\btwo[\s-]+pointers?\b/i, signals: /\b(?:left|lo|l)\b[\s\S]*\b(?:right|hi|r)\b/ },
89
+ { id: 'sliding-window', terms: /\bsliding[\s-]+windows?\b/i, signals: /\bwindow\b/i },
90
+ { id: 'prefix-sum', terms: /\bprefix[\s-]+sums?\b|\bcumulative\s+sums?\b|\brunning\s+sums?\b/i, signals: /\bprefix\b|\bcumsum\b|\baccumulate\b/i },
91
+ { id: 'dynamic-programming', terms: /\bdynamic\s+programming\b|\bmemoi[sz]\w*\b|\bdp\s+(?:table|array)\b|\btabulation\b|\b(?:top-down|bottom-up)\b/i, signals: /\bdp\b|\bmemo\b|\blru_cache\b|\bcache\b/i },
92
+ { id: 'bfs', terms: /\bbfs\b|\bbreadth[\s-]+first\b|\blevel[\s-]+order\b/i, signals: /\bdeque\b|\bqueue\b|\bpopleft\b/i },
93
+ { id: 'dfs', terms: /\bdfs\b|\bdepth[\s-]+first\b/i, signals: null },
94
+ { id: 'iterative', terms: /\biterative(?:ly)?\s+(?:version|approach|solution|dfs|bfs|traversal|implementation)\b|\bexplicit\s+stack\b/i, signals: null },
95
+ { id: 'graph-search', terms: /\btopological\s+sort\w*\b|\bdijkstra\w*\b|\bbellman[\s-]+ford\b|\bkruskal\w*\b|\bprim'?s\b|\bkahn'?s\b/i, signals: /\bindegree\b|\bdist\b|\bheapq\b/i },
96
+ { id: 'greedy', terms: /\bgreedy\b|\bgreedily\b/i, signals: null },
97
+ { id: 'backtracking', terms: /\bbacktrack\w*\b/i, signals: null },
98
+ { id: 'bits', terms: /\bbit\s*(?:manipulation|mask|masks|masking|wise)\b|\bbitmasks?\b|\bxor\b/i, signals: /\^|<<|>>|\bxor\b/i },
99
+ { id: 'sorting', terms: /\bsort(?:ing)?\s+(?:the|it|them|your|both|first|array|list|input|numbers|values)\b|\bsorted\s+order\b|\bafter\s+sorting\b/i, signals: /\bsort\s*\(|\bsorted\s*\(|\.sort\b|\bArrays\.sort\b/i },
100
+ { id: 'recursion', terms: /\brecursion\b|\brecursive(?:ly)?\b/i, signals: null },
101
+ ];
102
+
103
+ // Whether `code` calls a function it defines from inside (recursion), which
104
+ // makes DFS and recursion the student's own ideas.
105
+ function usesRecursion(code) {
106
+ const names = new Set();
107
+ for (const match of code.matchAll(/\b(?:def|function|func|fn)\s+([A-Za-z_]\w*)\s*\(/g)) names.add(match[1]);
108
+ for (const match of code.matchAll(/\b(?:int|void|bool|boolean|long|double|auto|string|String|List<[^>]*>|vector<[^>]*>)\s+([A-Za-z_]\w*)\s*\([^)]*\)\s*\{/g)) names.add(match[1]);
109
+ for (const name of names) {
110
+ const calls = code.match(new RegExp(`\\b${name}\\s*\\(`, 'g')) || [];
111
+ if (calls.length >= 2) return true;
112
+ }
113
+ return false;
114
+ }
115
+
116
+ // Text as the patterns read it: the non-breaking and other Unicode hyphens a
117
+ // model writes ("depth\u2011first") become '-', odd spaces become ' '.
118
+ export function matchable(text) {
119
+ return String(text || '').replace(/[\u2010-\u2012]/g, '-').replace(/[\u00A0\u2007\u202F]/g, ' ');
120
+ }
121
+
122
+ // The technique groups `material` (student words, code, replies already
123
+ // shown) has named, as a Set of ids.
124
+ export function namedTechniques({ prose: rawProse = '', code = '' } = {}) {
125
+ const prose = matchable(rawProse);
126
+ const named = new Set();
127
+ for (const technique of TECHNIQUES) {
128
+ if (technique.terms.test(prose) || technique.terms.test(code)) named.add(technique.id);
129
+ else if (technique.signals && code && technique.signals.test(code)) named.add(technique.id);
130
+ }
131
+ if (code && usesRecursion(code)) {
132
+ named.add('dfs');
133
+ named.add('recursion');
134
+ }
135
+ // Talking about a recursive DFS makes recursion named, and the reverse.
136
+ if (named.has('dfs')) named.add('recursion');
137
+ if (named.has('recursion')) named.add('dfs');
138
+ if (named.has('bfs')) named.add('queue');
139
+ return named;
140
+ }
141
+
142
+ // The technique terms in `text` whose group is not in `named`, as the
143
+ // matched words (first match per group).
144
+ export function unnamedTechniques(rawText, named) {
145
+ const text = matchable(rawText);
146
+ const found = [];
147
+ for (const technique of TECHNIQUES) {
148
+ if (named.has(technique.id)) continue;
149
+ const match = text.match(technique.terms);
150
+ if (match) found.push(match[0]);
151
+ }
152
+ return found;
153
+ }
154
+
155
+ // Technique groups whose code signals show up in `code` but not in `named`:
156
+ // a code block that brings in a structure the student never used.
157
+ function unnamedSignals(code, named) {
158
+ const found = [];
159
+ for (const technique of TECHNIQUES) {
160
+ if (named.has(technique.id) || !technique.signals) continue;
161
+ // Too loose to tell anything from a code block on their own.
162
+ if (technique.id === 'two-pointers' || technique.id === 'binary-search' || technique.id === 'union-find') continue;
163
+ const match = code.match(technique.signals);
164
+ if (match) found.push(match[0]);
165
+ }
166
+ return found;
167
+ }
168
+
169
+ // ---- patterns ----------------------------------------------------------------------
170
+
171
+ const ANY_COMPLEXITY_RE = /\bO\s*\(|\b(?:linear|constant|logarithmic|quadratic|polynomial)[\s-]+(?:time|space|lookups?)\b|\b(?:one|single)[\s-]+pass\b|\bin\s+one\s+go\b|\bsub-?quadratic\b/i;
172
+ const TARGET_COMPLEXITY_RE = /\b(?:possible|achievable|achieve|target|optimal|reduce[sd]?|down\s+to|improve[sd]?|bring\s+(?:it\s+)?down|can\s+be\s+done\s+in|could\s+be\s+done\s+in|faster|better)\b[^.!?\n]*\bO\s*\(|\bO\s*\([^)]*\)[^.!?\n]*\b(?:is\s+possible|is\s+achievable|is\s+optimal|is\s+the\s+best|would\s+be\s+(?:better|faster))\b|\b(?:in|with)\s+(?:a\s+)?(?:one|single)[\s-]+pass\b|\bconstant[\s-]+time\s+lookups?\b/i;
173
+ const EDGE_CASE_RE = /\bedge[\s-]+cases?\b|\bcorner[\s-]+cases?\b|\b(?:no|zero)\s+(?:two\s+numbers|pairs?|solutions?|valid\s+(?:pairs?|answers?))\b|\bempty\s+(?:list|array|input|grid|string)\b|\bif\s+(?:there\s+(?:is|are)\s+)?no\s+(?:two|pair|answer|solution)\b|\b(?:does\s+not|doesn'?t)\s+exist\b|\bduplicates?\b|\bnegative\s+numbers?\b/i;
174
+ const STUCK_QUESTION_RE = /\b(?:tried|try|attempted|so\s+far|unclear|confus\w*|stuck|which\s+(?:step|part)|what\s+part|walk\s+me\s+through)\b/i;
175
+ const IMPROVE_RE = /\b(?:improv\w*|optimi[sz]\w*|faster|more\s+efficient(?:ly)?|efficiency|speed\s+(?:it\s+)?up|better\s+(?:method|approach|way|algorithm|solution|complexity))\b/i;
176
+ const VERDICT_RE = /\b(?:accepted|would\s+pass|will\s+pass|should\s+pass|passes\s+all\s+(?:the\s+)?(?:tests?|test\s+cases|cases)|time\s+limit\s+exceeded|wrong\s+answer\s+verdict)\b/i;
177
+ const OPTIMAL_RE = /\b(?:optimal\s+(?:solution|approach|complexity)|(?:a\s+)?(?:better|more\s+efficient|faster)\s+(?:approach|solution|algorithm|method|way)\s+(?:would|is|exists|could)|you\s+could\s+(?:instead\s+)?(?:use|switch)|consider\s+using|switch(?:ing)?\s+to)\b/i;
178
+ const REPAIR_RE = /\b(?:to\s+fix\s+(?:this|it|that|the)|the\s+fix\s+is|fix(?:ed)?\s+(?:it|this)\s+by|change\s+(?:the|your|it|this|that|line)\b[^.!?\n]{0,80}\bto\b|should\s+(?:start|begin|instead|be\s+changed)|start\s+(?:the\s+inner\s+loop|j)\s+at|replace\b[^.!?\n]{0,60}\bwith\b|instead\s+of\b[^.!?\n]{0,60}\buse\b|you\s+(?:need|have)\s+to\s+(?:change|add|start|use)|(?:a|the)\s+corrected\s+version)\b/i;
179
+ // Lines that make a block program code: definitions, control flow,
180
+ // statements. A plain assignment of a literal (`nums = [1, 0, 2]`) or a call
181
+ // (`count_islands(grid)`) is an example input, not code.
182
+ const STRUCTURE_LINE_RE = /^\s*(?:def\s|class\s|return\b|for\s+\w+(?:\s*,\s*\w+)*\s+in\s|for\s*\(|while\s*[(\w]|if\s*\(|if\s.+:\s*$|elif\s|else\s*[:{]|else\s+if\b|function\b|public\s|private\s|static\s|void\s|#include|import\s|from\s+\w+\s+import|[{}]\s*;?\s*$|(?:const|let|var|int|long|bool|auto)\s+\w+\s*=.*;\s*$|\w+(?:\[[^\]]*\])*\s*(?:\+|-|\*|\/)=)/;
183
+ const CODE_LANGS = /^(?:python3?|py|java|cpp|c\+\+|c|cs|csharp|c#|js|javascript|ts|typescript|go|golang|rust|rs|kotlin|kt|swift|ruby|rb|php|scala|dart|sql|mysql)$/i;
184
+ const PLAN_ITEM_RE = /^\s*(?:\d+[.)]|[-*+]|step\s+\d+[:.])\s+\S/gim;
185
+
186
+ // Whether a fenced block is program code (rather than a diagram, a trace or
187
+ // an example input).
188
+ export function isCodeBlock(info, body) {
189
+ const lines = body.split('\n').filter((line) => line.trim());
190
+ const structural = lines.filter((line) => STRUCTURE_LINE_RE.test(line)).length;
191
+ if (CODE_LANGS.test((info || '').trim().split(/\s+/)[0] || '')) return structural >= 1;
192
+ return structural >= 2;
193
+ }
194
+
195
+ function normalizeLine(line) {
196
+ return line.replace(/\s+/g, ' ').trim();
197
+ }
198
+
199
+ // Whether every non-empty line of `body` is a line of the student's code
200
+ // (quoting their own code is always fine).
201
+ function quotesStudentCode(body, code) {
202
+ if (!code) return false;
203
+ const own = new Set(code.split('\n').map(normalizeLine).filter(Boolean));
204
+ const lines = body.split('\n').map(normalizeLine).filter(Boolean);
205
+ return lines.length > 0 && lines.every((line) => own.has(line));
206
+ }
207
+
208
+ // Inline code spans that look like code and are not quoted from the
209
+ // student's code.
210
+ function foreignInlineCode(text, code) {
211
+ const found = [];
212
+ const ownCode = normalizeLine(code || '');
213
+ for (const match of text.matchAll(/`([^`\n]+)`/g)) {
214
+ const span = match[1].trim();
215
+ if (!/[()=[\];{}]|\bfor\b|\bwhile\b|\breturn\b/.test(span)) continue;
216
+ if (ownCode.includes(normalizeLine(span))) continue;
217
+ found.push(span);
218
+ }
219
+ return found;
220
+ }
221
+
222
+ // ---- repairs -----------------------------------------------------------------------
223
+
224
+ // Fixes what never needs a re-ask, in text that is not code: an em dash or a
225
+ // spaced en dash becomes a comma, emoji are removed, Unicode arrows become
226
+ // ASCII. `text` must hold no fence (the gate repairs prose units only);
227
+ // inline code spans are left alone.
228
+ export function repairProse(text) {
229
+ return text
230
+ .split(/(`[^`\n]*`)/)
231
+ .map((part, index) => {
232
+ if (index % 2 === 1) return part;
233
+ return part
234
+ .replace(/[ \t\u00a0\u202f]*\u2014[ \t\u00a0\u202f]*/g, ', ')
235
+ .replace(/([A-Za-z.,)'"\u2019\u201d]{2,})[ \t\u00a0\u202f]+\u2013[ \t\u00a0\u202f]+(?=[A-Za-z(\u2018\u201c])/g, '$1, ')
236
+ .replace(/,(\s*,)+/g, ',')
237
+ .replace(/,\s*([.!?])/g, '$1')
238
+ .replace(/(?![\u00A9\u00AE\u203C\u2049\u2122\u2139\u2194-\u2199\u21A9\u21AA])\p{Extended_Pictographic}\uFE0F?[ \t]?/gu, '')
239
+ .replace(/\u2192|\u27F6|\u279C|\u2794/g, '->')
240
+ .replace(/\u2190|\u27F5/g, '<-')
241
+ .replace(/\u21D2|\u27F9/g, '=>')
242
+ .replace(/\u2194|\u27F7/g, '<->');
243
+ })
244
+ .join('');
245
+ }
246
+
247
+ // A fenced block as shown: program code is never touched; in a trace or a
248
+ // diagram, a Unicode arrow becomes ASCII only on a line with no box drawing
249
+ // or pipe, where the extra character cannot break an alignment.
250
+ export function repairBlock(unit) {
251
+ if (isCodeBlock(unit.info, unit.body)) return unit.text;
252
+ return unit.text
253
+ .split('\n')
254
+ .map((line) => (/[\u2500-\u257F|]/.test(line) ? line : line
255
+ .replace(/\u2192|\u27F6|\u279C|\u2794/g, '->')
256
+ .replace(/\u2190|\u27F5/g, '<-')
257
+ .replace(/\u21D2|\u27F9/g, '=>')))
258
+ .join('\n');
259
+ }
260
+
261
+ // ---- policies ----------------------------------------------------------------------
262
+
263
+ // What a turn of each kind may and may not contain. `mode` is how the gate
264
+ // releases text: 'hold' (all at the end, re-asked when broken), 'sentence'
265
+ // (checked unit by unit, broken units dropped) or 'word' (repairs only).
266
+ export function turnPolicy(kindInfo) {
267
+ const { kind, hintNumber = 1 } = kindInfo;
268
+ if (kind === 'hint') {
269
+ const first = hintNumber <= 1;
270
+ return {
271
+ kind,
272
+ hintNumber,
273
+ mode: 'hold',
274
+ maxChars: first ? FIRST_HINT_MAX_CHARS : LATER_HINT_MAX_CHARS,
275
+ noFence: first ? 'any' : 'code',
276
+ noForeignInlineCode: true,
277
+ noTechniques: first,
278
+ noComplexity: first ? 'any' : 'target',
279
+ noPlan: !first,
280
+ needsQuestion: false,
281
+ noImprove: first,
282
+ noEdgeCase: first,
283
+ fallback: first ? FALLBACK_TEXT.hint : FALLBACK_TEXT.laterHint,
284
+ };
285
+ }
286
+ if (kind === 'stuck') {
287
+ return {
288
+ kind,
289
+ mode: 'hold',
290
+ maxChars: STUCK_MAX_CHARS,
291
+ noFence: 'any',
292
+ noForeignInlineCode: true,
293
+ noTechniques: true,
294
+ noComplexity: 'any',
295
+ noPlan: true,
296
+ needsQuestion: true,
297
+ noImprove: true,
298
+ fallback: FALLBACK_TEXT.stuck,
299
+ };
300
+ }
301
+ if (kind === 'alternatives') {
302
+ return { kind, mode: 'sentence', dropFences: 'code', fallback: FALLBACK_TEXT.other };
303
+ }
304
+ if (kind === 'chat') {
305
+ return { kind, mode: 'sentence', dropFences: 'foreign', dropTechniques: true, dropTargets: true, dropRepairs: true, fallback: FALLBACK_TEXT.other };
306
+ }
307
+ if (kind === 'submit') {
308
+ return { kind, mode: 'sentence', dropFences: 'foreign', dropTechniques: true, dropTargets: true, dropVerdicts: true, fallback: FALLBACK_TEXT.other };
309
+ }
310
+ return { kind: 'answer', mode: 'word', fallback: FALLBACK_TEXT.other };
311
+ }
312
+
313
+ // ---- checks ------------------------------------------------------------------------
314
+
315
+ // Splits a whole reply into fenced blocks and prose sentences, in order:
316
+ // [{ type: 'fence', text, info, body } | { type: 'prose', text }]. Joining
317
+ // every unit's text gives the reply back.
318
+ export function splitUnits(text) {
319
+ const gate = createGate({ mode: 'sentence' }, { named: new Set(), code: '' }, { collect: true });
320
+ gate.push(text);
321
+ gate.finish();
322
+ return gate.units;
323
+ }
324
+
325
+ // The rule a held reply breaks, as violations [{ rule, detail }], for the
326
+ // whole text. `context` is { named, code }.
327
+ export function checkHeld(text, policy, context) {
328
+ const violations = [];
329
+ const units = splitUnits(text);
330
+ const prose = matchable(units.filter((unit) => unit.type === 'prose').map((unit) => unit.text).join(''));
331
+ for (const unit of units) {
332
+ if (unit.type !== 'fence') continue;
333
+ if (policy.noFence === 'any' || (policy.noFence === 'code' && isCodeBlock(unit.info, unit.body) && !quotesStudentCode(unit.body, context.code))) {
334
+ violations.push({ rule: 'fence' });
335
+ break;
336
+ }
337
+ }
338
+ if (policy.noForeignInlineCode) {
339
+ const spans = foreignInlineCode(prose, context.code);
340
+ if (spans.length) violations.push({ rule: 'inline-code', detail: spans[0] });
341
+ }
342
+ if (policy.noTechniques) {
343
+ const terms = unnamedTechniques(prose, context.named);
344
+ if (terms.length) violations.push({ rule: 'technique', detail: terms.join(', ') });
345
+ }
346
+ if (policy.noComplexity === 'any') {
347
+ const match = prose.match(ANY_COMPLEXITY_RE);
348
+ if (match) violations.push({ rule: 'complexity', detail: match[0] });
349
+ } else if (policy.noComplexity === 'target') {
350
+ const match = prose.match(TARGET_COMPLEXITY_RE);
351
+ if (match) violations.push({ rule: 'complexity', detail: match[0] });
352
+ }
353
+ if (policy.noImprove) {
354
+ const match = prose.match(IMPROVE_RE);
355
+ if (match) violations.push({ rule: 'improve', detail: match[0] });
356
+ }
357
+ if (policy.noEdgeCase) {
358
+ const match = prose.match(EDGE_CASE_RE);
359
+ if (match) violations.push({ rule: 'edge-case', detail: match[0] });
360
+ }
361
+ if (policy.noPlan && (prose.match(PLAN_ITEM_RE) || []).length >= 3) violations.push({ rule: 'plan' });
362
+ if (policy.needsQuestion && !askedWhatTried(prose)) violations.push({ rule: 'question' });
363
+ if (policy.maxChars && text.trim().length > policy.maxChars) violations.push({ rule: 'length' });
364
+ return violations;
365
+ }
366
+
367
+ // Whether some question in `prose` asks what the student tried or which step
368
+ // is unclear.
369
+ function askedWhatTried(prose) {
370
+ return prose.split(/(?<=[.!?])\s+/).some((sentence) => sentence.includes('?') && STUCK_QUESTION_RE.test(sentence));
371
+ }
372
+
373
+ // Why a streamed unit must be dropped (a short rule name), or null.
374
+ function dropReason(unit, policy, context) {
375
+ if (unit.type === 'fence') {
376
+ if (!policy.dropFences) return null;
377
+ if (quotesStudentCode(unit.body, context.code)) return null;
378
+ if (policy.dropFences === 'code') return isCodeBlock(unit.info, unit.body) ? 'fence' : null;
379
+ // 'foreign': program code that is not a quote of theirs and either
380
+ // repairs it unasked or brings in a structure they never used.
381
+ if (!isCodeBlock(unit.info, unit.body)) return null;
382
+ if (policy.dropRepairs) return 'fence';
383
+ return unnamedSignals(unit.body, context.named).length || unnamedTechniques(unit.body, context.named).length ? 'fence' : null;
384
+ }
385
+ const text = matchable(unit.text);
386
+ if (policy.dropVerdicts && VERDICT_RE.test(text)) return 'verdict';
387
+ if (policy.dropTechniques && unnamedTechniques(text, context.named).length) return 'technique';
388
+ if (policy.dropTargets && (TARGET_COMPLEXITY_RE.test(text) || OPTIMAL_RE.test(text))) return 'complexity';
389
+ if (policy.dropRepairs && REPAIR_RE.test(text)) return 'repair';
390
+ return null;
391
+ }
392
+
393
+ // The re-ask: what the rejected reply broke, in the words of the rules, and
394
+ // what to do instead. The rejected reply is in the request right before it.
395
+ const RULE_TEXT = {
396
+ fence: 'It contains a code block. This reply must have no code.',
397
+ 'inline-code': 'It shows code the student did not write. Use plain words about their code.',
398
+ technique: 'It names a method or data structure the student has not mentioned',
399
+ complexity: 'It mentions a target complexity or speed',
400
+ improve: 'It points toward improving or optimizing the code',
401
+ plan: 'It lays out a step-by-step plan. Give one nudge instead.',
402
+ question: 'It does not ask what the student tried or which step is unclear.',
403
+ length: 'It is too long.',
404
+ 'edge-case': 'It is about an edge case. A first hint asks about what their code does or repeats',
405
+ verdict: 'It guesses a LeetCode verdict.',
406
+ repair: 'It gives a fix the student did not ask for.',
407
+ };
408
+
409
+ export function reaskPrompt(violations, policy) {
410
+ const lines = violations.map((violation) => {
411
+ const base = RULE_TEXT[violation.rule] || violation.rule;
412
+ const detail = violation.detail && ['technique', 'complexity', 'improve', 'edge-case'].includes(violation.rule)
413
+ ? ` ("${violation.detail}").`
414
+ : base.endsWith('.') ? '' : '.';
415
+ return `- ${base}${detail}`;
416
+ });
417
+ const shape = policy.kind !== 'stuck' && policy.kind !== 'hint'
418
+ ? "Answer the student's last message again without those parts."
419
+ : policy.kind === 'stuck'
420
+ ? 'Reply with only a short question about what they tried or which step is unclear, and offer a hint. Give no hint or method now.'
421
+ : policy.hintNumber > 1
422
+ ? 'Reply with one hint, one notch more concrete than the last, about their actual code, in plain words.'
423
+ : 'Reply with one short question about what their code does or about one property of the input. Name no method, data structure or complexity.';
424
+ return `Your reply above breaks the tutoring rules:\n${lines.join('\n')}\nRewrite it so it follows every rule. ${shape} Reply with the rewritten answer only.`;
425
+ }
426
+
427
+ // What is left of a held reply once every sentence and block that breaks a
428
+ // rule is removed, cut to its length limit; '' when nothing safe is left.
429
+ export function salvage(text, policy, context) {
430
+ if (typeof text !== 'string' || !text.trim()) return '';
431
+ const kept = [];
432
+ let length = 0;
433
+ for (const unit of splitUnits(text)) {
434
+ if (unit.type === 'fence') continue;
435
+ const piece = repairProse(unit.text);
436
+ if (!piece.trim()) {
437
+ if (kept.length) kept.push(piece);
438
+ continue;
439
+ }
440
+ if (checkHeld(piece, { ...policy, maxChars: 0, needsQuestion: false, noPlan: false }, context).length) continue;
441
+ if (/^\s*(?:\d+[.)]|[-*+])\s/.test(piece) && policy.noPlan) continue;
442
+ if (policy.maxChars && length + piece.trim().length > policy.maxChars) break;
443
+ kept.push(piece);
444
+ length += piece.trim().length;
445
+ }
446
+ const result = kept.join('').replace(/\n{3,}/g, '\n\n').trim().replace(/:$/, '.');
447
+ if (!result) return '';
448
+ if (policy.needsQuestion && !askedWhatTried(matchable(result))) return '';
449
+ return result;
450
+ }
451
+
452
+
453
+ // ---- the streaming gate --------------------------------------------------------------
454
+
455
+ const FENCE_OPEN_RE = /^ {0,3}(`{3,}|~{3,})([^\n`]*)\n/;
456
+ const FENCE_OPEN_PARTIAL_RE = /^ {0,3}(?:`{1,2}|~{1,2}|(?:`{3,}|~{3,})[^\n`]*)$/;
457
+ const FOLLOW_UP_RE = /^\s*(?:[-*+]\s+)?(?:this\s+way|that\s+way|now\b|then\b|with\s+(?:this|that|the)\s+(?:change|fix)|after\s+(?:this|that|the)\s+(?:change|fix)|this\s+(?:change|fix|ensures|guarantees|makes|prevents|avoids)|that\s+(?:change|fix)|either\s+way)/i;
458
+ const BLOCK_LINE_RE = /^\s*(?:[-*+]\s|\d+[.)]\s|#{1,6}\s|\||>)/;
459
+ const SPACE_RE = /[ \t\u00A0\u202F]/;
460
+ const DASH_RE = /[\u2013\u2014-]/;
461
+
462
+ // Takes a reply chunk by chunk and returns, from each push, the text that
463
+ // may be shown now:
464
+ // - 'word' releases up to the last safe word break (never right after a
465
+ // dash, so a dash repair sees both sides) and code blocks line by line;
466
+ // - 'sentence' releases whole checked sentences (or lines) and whole code
467
+ // blocks, and drops a broken one together with a lead-in ending in ':';
468
+ // - 'hold' releases nothing (the caller checks `raw` at the end).
469
+ // Code blocks are never repaired. `collect` only splits (see splitUnits).
470
+ function createGate(policy, context, { collect = false } = {}) {
471
+ const mode = collect ? 'collect' : policy.mode;
472
+ let all = '';
473
+ let pending = '';
474
+ let released = '';
475
+ let fence = null; // { marker, info, open, body, close } inside a fenced block
476
+ let atLineStart = true;
477
+ let leadIn = null; // a prose unit ending with ':', waiting for what follows
478
+ let afterDrop = false;
479
+ let lastDropped = false;
480
+ let carryBreak = false;
481
+ const units = [];
482
+ const dropped = [];
483
+
484
+ function emit(text) {
485
+ if (!text) return '';
486
+ let out = text;
487
+ if (!released) {
488
+ // Nothing shown yet: no leading blank lines.
489
+ out = out.replace(/^\s+/, '');
490
+ } else if (carryBreak && !released.endsWith('\n')) {
491
+ // A dropped unit took its line break with it: put it back.
492
+ out = `\n${out}`;
493
+ } else if (afterDrop && released.endsWith('\n') && out.startsWith('\n')) {
494
+ // Never more than one blank line where units were dropped.
495
+ out = out.replace(/^\n+/, released.endsWith('\n\n') ? '' : '\n');
496
+ }
497
+ carryBreak = false;
498
+ if (!out) return '';
499
+ afterDrop = false;
500
+ released += out;
501
+ return out;
502
+ }
503
+
504
+ // A complete unit in 'sentence' mode: kept, dropped, or held as a lead-in.
505
+ function settle(unit) {
506
+ if (mode === 'collect') {
507
+ units.push(unit);
508
+ return '';
509
+ }
510
+ // A sentence that only follows up on a dropped part ("This way j is
511
+ // always larger") goes with it.
512
+ const reason = dropReason(unit, policy, context) || (lastDropped && unit.type === 'prose' && FOLLOW_UP_RE.test(matchable(unit.text)) ? 'follow-up' : null);
513
+ if (reason) {
514
+ lastDropped = true;
515
+ dropped.push({ rule: reason, text: unit.text });
516
+ if (unit.text.endsWith('\n')) carryBreak = true;
517
+ leadIn = null;
518
+ afterDrop = true;
519
+ return '';
520
+ }
521
+ const text = unit.type === 'fence' ? repairBlock(unit) : repairProse(unit.text);
522
+ if (leadIn !== null && unit.type === 'prose' && !text.trim()) {
523
+ leadIn += text;
524
+ return '';
525
+ }
526
+ if (text.trim()) lastDropped = false;
527
+ let out = '';
528
+ if (leadIn !== null) {
529
+ out += emit(leadIn);
530
+ leadIn = null;
531
+ }
532
+ if (unit.type === 'prose' && /:\s*$/.test(text)) {
533
+ leadIn = text;
534
+ return out;
535
+ }
536
+ return out + emit(text);
537
+ }
538
+
539
+ function closeFence() {
540
+ const unit = { type: 'fence', info: fence.info, body: fence.body.replace(/\n$/, ''), text: fence.open + fence.body + fence.close };
541
+ const out = mode === 'word' ? emit(fence.close) : settle(unit);
542
+ fence = null;
543
+ atLineStart = true;
544
+ return out;
545
+ }
546
+
547
+ // Splits complete units off the front of `pending`; `final` means no more
548
+ // text is coming.
549
+ function drain(final) {
550
+ let out = '';
551
+ while (pending) {
552
+ if (fence) {
553
+ const closing = new RegExp(`^ {0,3}${fence.marker[0] === '`' ? '`' : '~'}{${fence.marker.length},}[ \\t]*$`);
554
+ let closed = false;
555
+ for (;;) {
556
+ const newline = pending.indexOf('\n');
557
+ if (newline < 0 && !final) break;
558
+ const line = newline < 0 ? pending : pending.slice(0, newline);
559
+ const piece = newline < 0 ? pending : pending.slice(0, newline + 1);
560
+ pending = pending.slice(piece.length);
561
+ if (closing.test(line)) {
562
+ fence.close = piece;
563
+ closed = true;
564
+ break;
565
+ }
566
+ fence.body += piece;
567
+ if (mode === 'word') out += emit(piece);
568
+ if (!pending) break;
569
+ }
570
+ if (closed || (final && !pending)) {
571
+ out += closeFence();
572
+ continue;
573
+ }
574
+ break;
575
+ }
576
+ if (atLineStart) {
577
+ const open = pending.match(FENCE_OPEN_RE);
578
+ if (open || (final && /^ {0,3}(`{3,}|~{3,})([^\n`]*)$/.test(pending))) {
579
+ const match = open || pending.match(/^ {0,3}(`{3,}|~{3,})([^\n`]*)$/);
580
+ fence = { marker: match[1], info: match[2].trim(), open: match[0], body: '', close: '' };
581
+ pending = pending.slice(match[0].length);
582
+ if (mode === 'word') out += emit(match[0]);
583
+ continue;
584
+ }
585
+ if (!final && FENCE_OPEN_PARTIAL_RE.test(pending)) break;
586
+ }
587
+ const end = proseUnitEnd(pending, final, mode === 'word');
588
+ if (end <= 0) break;
589
+ const text = pending.slice(0, end);
590
+ pending = pending.slice(end);
591
+ atLineStart = text.endsWith('\n');
592
+ out += mode === 'word' ? emit(repairProse(text)) : settle({ type: 'prose', text });
593
+ }
594
+ // A block still open when the reply ends is a whole (unclosed) block.
595
+ if (final && fence) out += closeFence();
596
+ return out;
597
+ }
598
+
599
+ return {
600
+ units,
601
+ dropped,
602
+ push(chunk) {
603
+ if (typeof chunk !== 'string' || !chunk) return '';
604
+ all += chunk;
605
+ if (mode === 'hold') return '';
606
+ pending += chunk;
607
+ return drain(false);
608
+ },
609
+ finish() {
610
+ if (mode === 'hold') return '';
611
+ let out = drain(true);
612
+ if (leadIn !== null) {
613
+ out += emit(leadIn);
614
+ leadIn = null;
615
+ }
616
+ return out;
617
+ },
618
+ // Everything pushed so far.
619
+ get raw() {
620
+ return all;
621
+ },
622
+ get released() {
623
+ return released;
624
+ },
625
+ };
626
+ }
627
+
628
+ // Where the first complete prose unit at the start of `text` ends (an index
629
+ // past it), or 0 when none is complete yet. A unit ends after a newline, or
630
+ // (sentence units) after a sentence end (. ! ? and closing marks) and the
631
+ // spaces after it, or (word units) before the last
632
+ // space that has a plain character after it and no dash before it. Inline
633
+ // code spans are never split.
634
+ function proseUnitEnd(text, final, word) {
635
+ let inCode = false;
636
+ let lastWordBreak = 0;
637
+ for (let i = 0; i < text.length; i += 1) {
638
+ const ch = text[i];
639
+ if (ch === '\n') {
640
+ if (word) return i + 1;
641
+ // A model that hard-wraps its prose breaks lines mid-sentence: such a
642
+ // line stays in the unit with the next one, so a dropped sentence is
643
+ // never cut in half. Only a line that ends a sentence, or a block
644
+ // line (list item, heading, table row, quote), or one followed by a
645
+ // new block, ends the unit here.
646
+ const line = text.slice(text.lastIndexOf('\n', i - 1) + 1, i);
647
+ if (!line.trim() || BLOCK_LINE_RE.test(line) || /[.!?:]["')\]*_\u2019\u201D]*[ \t]*$/.test(line)) return i + 1;
648
+ const rest = text.slice(i + 1);
649
+ const nextEnd = rest.indexOf('\n');
650
+ const next = nextEnd < 0 ? rest : rest.slice(0, nextEnd);
651
+ if (nextEnd < 0 && next.length < 4 && !final) return 0;
652
+ if (!next.trim() || BLOCK_LINE_RE.test(next) || /^ {0,3}(?:`{3}|~{3})/.test(next)) return i + 1;
653
+ continue;
654
+ }
655
+ if (ch === '`') {
656
+ let run = 1;
657
+ while (text[i + run] === '`') run += 1;
658
+ if (run < 3) inCode = !inCode;
659
+ i += run - 1;
660
+ continue;
661
+ }
662
+ if (inCode) continue;
663
+ if (word) {
664
+ if (SPACE_RE.test(ch) && i + 1 < text.length && !/\s/.test(text[i + 1]) && !DASH_RE.test(text[i + 1])) {
665
+ let j = i - 1;
666
+ while (j >= 0 && SPACE_RE.test(text[j])) j -= 1;
667
+ if (j >= 0 && !DASH_RE.test(text[j])) lastWordBreak = i;
668
+ }
669
+ continue;
670
+ }
671
+ if (ch === '.' || ch === '!' || ch === '?') {
672
+ let j = i + 1;
673
+ while (j < text.length && /["')\]*_\u2019\u201D]/.test(text[j])) j += 1;
674
+ if (j < text.length && SPACE_RE.test(text[j])) {
675
+ let k = j;
676
+ while (k < text.length && SPACE_RE.test(text[k])) k += 1;
677
+ // "e.g." and the like are not sentence ends.
678
+ if (ch === '.' && /\b(?:e\.g|i\.e|etc|vs|approx|Dr|Mr|Ms|Mrs)$/i.test(text.slice(Math.max(0, i - 6), i))) continue;
679
+ // Nor is a list marker ("1. ") at the start of a line.
680
+ if (ch === '.' && /^[ \t]*\d+$/.test(text.slice(text.lastIndexOf('\n', i) + 1, i))) continue;
681
+ // Released as soon as its trailing space arrives (more spaces, or a
682
+ // newline, start the next unit), so a sentence never waits for the
683
+ // next one to begin.
684
+ if (k === text.length || text[k] !== '\n') return k;
685
+ }
686
+ }
687
+ }
688
+ if (word && lastWordBreak > 0) return lastWordBreak;
689
+ return final ? text.length : 0;
690
+ }
691
+
692
+ // ---- the guard ----------------------------------------------------------------------
693
+
694
+ const MAX_MATERIAL_CHARS = 60_000;
695
+
696
+ // One guard per tutor conversation. It remembers how many hints were given
697
+ // and what the student has named or already seen (their messages, their
698
+ // code, every reply shown), and is reset with the conversation (a problem
699
+ // switch, /reset).
700
+ export function createReplyGuard() {
701
+ let hintsGiven = 0;
702
+ let seenProse = '';
703
+ let seenCode = '';
704
+ const keep = (value) => (value.length > MAX_MATERIAL_CHARS ? value.slice(-MAX_MATERIAL_CHARS) : value);
705
+
706
+ return {
707
+ // The policy and context for one turn: `text` is the student's own
708
+ // words (null for a Submit), `trigger` 'submit' or null, `code` their
709
+ // current code (or null). `done(shown)` records the finished turn
710
+ // (guardedSend calls it).
711
+ begin({ text = null, trigger = null, code = null } = {}) {
712
+ const words = typeof text === 'string' ? text : '';
713
+ const current = typeof code === 'string' ? code : '';
714
+ const policy = turnPolicy(classifyMessage(words, { trigger, hintsGiven }));
715
+ const context = {
716
+ named: namedTechniques({ prose: `${seenProse}\n${words}`, code: `${seenCode}\n${current}` }),
717
+ code: current,
718
+ };
719
+ return {
720
+ policy,
721
+ context,
722
+ done(shown) {
723
+ if (policy.kind === 'hint') hintsGiven += 1;
724
+ seenProse = keep(`${seenProse}\n${words}\n${typeof shown === 'string' ? shown : ''}`);
725
+ if (current) seenCode = keep(current);
726
+ },
727
+ };
728
+ },
729
+ reset() {
730
+ hintsGiven = 0;
731
+ seenProse = '';
732
+ seenCode = '';
733
+ },
734
+ get hintsGiven() {
735
+ return hintsGiven;
736
+ },
737
+ };
738
+ }
739
+
740
+ // Runs one tutor turn through the guard.
741
+ // - `backend.sendMessage(text, options)` is called with `onChunk` and, for
742
+ // a held turn, `shouldStop(textSoFar)` (a backend that supports it ends
743
+ // the request early and returns what it had).
744
+ // - `onText(piece)` receives each piece the student may see, in order; the
745
+ // resolved string is the whole reply as shown, always exactly the
746
+ // concatenation of those pieces.
747
+ // - A backend with `reviseLastReply(instruction, { shouldStop })` gets one
748
+ // re-ask for a broken held reply (and for a streamed one whose every
749
+ // part was dropped); one with `replaceLastReply(text)` keeps the shown
750
+ // text, never the rejected one, in its history.
751
+ // - Backend errors propagate unchanged, except that a cut-off error's
752
+ // `partialReply` becomes what may be shown of it.
753
+ // `turn` comes from guard.begin(); null sends the turn unguarded.
754
+ export async function guardedSend({ backend, text, options = {}, turn, onText = () => {}, log = () => {} }) {
755
+ if (!turn) return backend.sendMessage(text, { ...options, onChunk: onText });
756
+ const { policy, context } = turn;
757
+ const gate = createGate(policy, context);
758
+ let shown = '';
759
+ const show = (piece) => {
760
+ if (!piece) return;
761
+ shown += piece;
762
+ onText(piece);
763
+ };
764
+ let chunks = 0;
765
+ const hold = policy.mode === 'hold';
766
+ const shouldStop = hold ? heldStop(policy) : undefined;
767
+
768
+ let reply;
769
+ try {
770
+ reply = await backend.sendMessage(text, {
771
+ ...options,
772
+ onChunk: (chunk) => {
773
+ chunks += 1;
774
+ show(gate.push(chunk));
775
+ },
776
+ ...(shouldStop ? { shouldStop } : {}),
777
+ });
778
+ } catch (err) {
779
+ if (err && err.cutOff) {
780
+ if (!chunks && typeof err.partialReply === 'string') show(gate.push(err.partialReply));
781
+ show(gate.finish());
782
+ if (hold) show(salvage(gate.raw, policy, context));
783
+ err.partialReply = shown || undefined;
784
+ }
785
+ throw err;
786
+ }
787
+ if (!(typeof reply === 'string' && reply.trim())) return reply;
788
+ // A backend that did not stream hands over the whole reply at the end.
789
+ if (!chunks) show(gate.push(reply));
790
+ show(gate.finish());
791
+
792
+ if (hold) {
793
+ const raw = gate.raw;
794
+ const violations = checkHeld(raw, policy, context);
795
+ if (!violations.length) {
796
+ show(repairHeld(raw));
797
+ } else {
798
+ log(`guard: a ${policy.kind} reply broke ${violations.map((v) => v.rule).join(', ')}; re-asking once`);
799
+ show(await reaskHeld({ backend, original: raw, violations, policy, context, log }));
800
+ }
801
+ } else if (gate.dropped.length) {
802
+ log(`guard: dropped ${gate.dropped.map((d) => d.rule).join(', ')} from a ${policy.kind} reply`);
803
+ if (!shown.trim()) {
804
+ // Every part was dropped and nothing is on screen yet, so a re-ask
805
+ // cannot replace anything the student saw.
806
+ show(await reaskStreamed({ backend, violations: gate.dropped.map((d) => ({ rule: d.rule })), policy, context, log }));
807
+ }
808
+ }
809
+ if (shown !== reply && typeof backend.replaceLastReply === 'function') backend.replaceLastReply(shown);
810
+ turn.done(shown);
811
+ return shown;
812
+ }
813
+
814
+ // When a held reply is past saving anyway: well over its length limit, or
815
+ // (where no code block is allowed) a code block has started.
816
+ function heldStop(policy) {
817
+ const limit = policy.maxChars ? Math.ceil(policy.maxChars * EARLY_STOP_FACTOR) : Infinity;
818
+ return (soFar) => soFar.length > limit || (policy.noFence === 'any' && /(?:^|\n) {0,3}(?:```|~~~)/.test(soFar));
819
+ }
820
+
821
+ // A held reply's text as shown: prose repaired, code blocks as they are.
822
+ function repairHeld(text) {
823
+ return splitUnits(text)
824
+ .map((unit) => (unit.type === 'fence' ? repairBlock(unit) : repairProse(unit.text)))
825
+ .join('')
826
+ .trim();
827
+ }
828
+
829
+ async function revise(backend, violations, policy, log) {
830
+ if (typeof backend.reviseLastReply !== 'function') return null;
831
+ try {
832
+ const revised = await backend.reviseLastReply(reaskPrompt(violations, policy), { shouldStop: heldStop(policy) });
833
+ return typeof revised === 'string' && revised.trim() ? revised : null;
834
+ } catch (err) {
835
+ log(`guard: the re-ask failed (${err.message})`);
836
+ return null;
837
+ }
838
+ }
839
+
840
+ // One re-ask of a held reply; if that breaks a rule too, what is safe of the
841
+ // re-ask, then of the original, then the fallback.
842
+ async function reaskHeld({ backend, original, violations, policy, context, log }) {
843
+ const revised = await revise(backend, violations, policy, log);
844
+ if (revised) {
845
+ const again = checkHeld(revised, policy, context);
846
+ if (!again.length) return repairHeld(revised);
847
+ log(`guard: the re-ask broke ${again.map((v) => v.rule).join(', ')} too`);
848
+ const kept = salvage(revised, policy, context);
849
+ if (kept) return kept;
850
+ }
851
+ return salvage(original, policy, context) || policy.fallback || FALLBACK_TEXT.other;
852
+ }
853
+
854
+ // One re-ask of a streamed reply that had nothing left to show, put through
855
+ // the same sentence checks; then the fallback.
856
+ async function reaskStreamed({ backend, violations, policy, context, log }) {
857
+ const revised = await revise(backend, violations, policy, log);
858
+ if (revised) {
859
+ const gate = createGate(policy, context);
860
+ const text = (gate.push(revised) + gate.finish()).trim();
861
+ if (text) return text;
862
+ }
863
+ return policy.fallback || FALLBACK_TEXT.other;
864
+ }