leetcode-capture-helper 0.0.0-stage → 0.1.0
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/cli.js +92 -0
- package/config.js +25 -0
- package/framing.js +57 -0
- package/host.js +346 -0
- package/install.js +290 -0
- package/package.json +31 -4
- package/runtime/bundle.json +42 -0
- package/runtime/companion/capture-log.js +83 -0
- package/runtime/companion/codex-backend.js +337 -0
- package/runtime/companion/companion.js +2193 -0
- package/runtime/companion/mouse-input.js +154 -0
- package/runtime/companion/package.json +4 -0
- package/runtime/companion/panel-bridge.js +273 -0
- package/runtime/companion/settings-rpc.js +228 -0
- package/runtime/companion/settings.js +464 -0
- package/runtime/companion/terminal-format.js +330 -0
- package/runtime/companion/vault-summary.js +169 -0
- package/runtime/companion/virtual-terminal.js +138 -0
- package/runtime/extension/shared/anthropic-backend.js +193 -0
- package/runtime/extension/shared/catalog.js +415 -0
- package/runtime/extension/shared/code-ledger.js +333 -0
- package/runtime/extension/shared/context-budget.js +259 -0
- package/runtime/extension/shared/context-summary.js +271 -0
- package/runtime/extension/shared/conversation.js +81 -0
- package/runtime/extension/shared/event-hub.js +126 -0
- package/runtime/extension/shared/extension-settings.js +445 -0
- package/runtime/extension/shared/helper-launcher.js +248 -0
- package/runtime/extension/shared/local-backend.js +706 -0
- package/runtime/extension/shared/local-server.js +149 -0
- package/runtime/extension/shared/package.json +4 -0
- package/runtime/extension/shared/practice.js +228 -0
- package/runtime/extension/shared/problem-index.js +4128 -0
- package/runtime/extension/shared/profiles.js +106 -0
- package/runtime/extension/shared/providers.js +58 -0
- package/runtime/extension/shared/reply-guard.js +864 -0
- package/runtime/extension/shared/roadmap.js +1344 -0
- package/runtime/extension/shared/sanitize.js +68 -0
- package/runtime/extension/shared/solved.js +277 -0
- package/runtime/extension/shared/tutor-engine.js +369 -0
- package/runtime/extension/shared/tutor-prompt.js +108 -0
- package/runtime/extension/shared/tutor-service.js +520 -0
- package/runtime/relay-server/package.json +4 -0
- package/runtime/relay-server/panel-broker.js +797 -0
- package/runtime/relay-server/server.js +241 -0
- package/runtime/vault-tool/log-session.js +230 -0
- package/runtime/vault-tool/package.json +4 -0
- package/runtime/vault-tool/vault-notes.js +206 -0
- package/README.md +0 -3
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// extension/shared/context-summary.js - condenses the oldest exchanges of a tutor
|
|
2
|
+
// conversation into one short "earlier in this session" summary instead of
|
|
3
|
+
// dropping them, like Claude Code's /compact.
|
|
4
|
+
//
|
|
5
|
+
// LocalBackend (companion.js) fits every request to the model's prompt
|
|
6
|
+
// budget, and CodexBackend (codex-backend.js) keeps a bounded number of
|
|
7
|
+
// exchanges. Both used to drop the oldest whole exchanges outright, so the
|
|
8
|
+
// tutor forgot what the student had already tried and which hints it had
|
|
9
|
+
// given. Now those exchanges are folded into one summary (at most
|
|
10
|
+
// SUMMARY_MAX_TOKENS by the companion's own estimate), sent as a system
|
|
11
|
+
// message after the pinned problem and code and before the live history
|
|
12
|
+
// (context-budget.js's buildMessages). A later overflow merges the newly
|
|
13
|
+
// dropped exchanges into it, so it stays bounded. The pinned problem, the
|
|
14
|
+
// pinned code and the current message are never summarized.
|
|
15
|
+
//
|
|
16
|
+
// The summary comes from one model call on the same backend (a short
|
|
17
|
+
// instruction plus only the exchanges being dropped, which already fit
|
|
18
|
+
// before). When that call fails, times out, returns nothing or would not fit
|
|
19
|
+
// the window itself, a deterministic digest is used instead: one line per
|
|
20
|
+
// dropped exchange from the student's first sentence and the tutor's first
|
|
21
|
+
// sentence. When not even a summary fits, the oldest exchanges are dropped
|
|
22
|
+
// as before. None of this prints anything: a fixable overflow is silent.
|
|
23
|
+
//
|
|
24
|
+
// Pure apart from the injected `summarize` call: no Node API, no I/O, so it
|
|
25
|
+
// is unit-tested directly (context-summary.test.js) and could run in the
|
|
26
|
+
// browser extension.
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
CHARS_PER_TOKEN_ESTIMATE,
|
|
30
|
+
SUMMARY_HEADER,
|
|
31
|
+
buildMessages,
|
|
32
|
+
estimateMessagesTokens,
|
|
33
|
+
estimateTokens,
|
|
34
|
+
fitToBudget,
|
|
35
|
+
summaryContent,
|
|
36
|
+
} from './context-budget.js';
|
|
37
|
+
|
|
38
|
+
export { SUMMARY_HEADER, summaryContent };
|
|
39
|
+
|
|
40
|
+
// The summary's own size cap, by the companion's estimate.
|
|
41
|
+
export const SUMMARY_MAX_TOKENS = 300;
|
|
42
|
+
// Below this much room a summary is not worth keeping: plain dropping wins.
|
|
43
|
+
export const SUMMARY_MIN_TOKENS = 60;
|
|
44
|
+
// How long the summary call may take before the digest is used instead.
|
|
45
|
+
export const SUMMARY_TIMEOUT_MS = 60_000;
|
|
46
|
+
// One digest line's cap, in characters, for each side of an exchange.
|
|
47
|
+
const DIGEST_STUDENT_CHARS = 120;
|
|
48
|
+
const DIGEST_TUTOR_CHARS = 160;
|
|
49
|
+
|
|
50
|
+
// Marks the summary call's own system message, so a log or a test stub can
|
|
51
|
+
// tell it apart from a tutor turn.
|
|
52
|
+
export const SUMMARIZER_SYSTEM =
|
|
53
|
+
'You condense the earlier part of a programming tutoring session into a short summary for the tutor to keep. ' +
|
|
54
|
+
'Write plain sentences or short bullet lines, no headings, and never use the em dash.';
|
|
55
|
+
|
|
56
|
+
// The most characters a summary body may have so the whole summary message
|
|
57
|
+
// stays within `tokens` by the estimate.
|
|
58
|
+
export function summaryBodyChars(tokens) {
|
|
59
|
+
return Math.max(0, Math.floor(tokens * CHARS_PER_TOKEN_ESTIMATE) - SUMMARY_HEADER.length - 1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Whole exchanges at the start of `history` ([system, (user, assistant)*,
|
|
63
|
+
// current]): how many there are.
|
|
64
|
+
export function countPairs(history) {
|
|
65
|
+
let pairs = 0;
|
|
66
|
+
for (let i = 1; i + 2 < history.length && history[i]?.role === 'user' && history[i + 1]?.role === 'assistant'; i += 2) {
|
|
67
|
+
pairs += 1;
|
|
68
|
+
}
|
|
69
|
+
return pairs;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function withoutPairs(history, k) {
|
|
73
|
+
return [history[0], ...history.slice(1 + 2 * k)];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// What a dropped exchange looked like on the wire: an older user turn whose
|
|
77
|
+
// code is pinned goes as its compact form, the same rule as buildMessages.
|
|
78
|
+
function renderedPairs(history, k, pinnedCode) {
|
|
79
|
+
const out = [];
|
|
80
|
+
for (let i = 0; i < k; i += 1) {
|
|
81
|
+
const user = history[1 + 2 * i];
|
|
82
|
+
const assistant = history[2 + 2 * i];
|
|
83
|
+
const compact = Boolean(pinnedCode) && typeof user.compactContent === 'string';
|
|
84
|
+
out.push({ student: compact ? user.compactContent : user.content, tutor: assistant.content });
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Cuts `text` to at most `max` characters at a word boundary.
|
|
90
|
+
export function clampText(text, max) {
|
|
91
|
+
const clean = String(text || '').trim();
|
|
92
|
+
if (clean.length <= max) return clean;
|
|
93
|
+
if (max <= 3) return '';
|
|
94
|
+
const cut = clean.slice(0, max - 3);
|
|
95
|
+
const space = cut.lastIndexOf(' ');
|
|
96
|
+
return `${(space > max / 2 ? cut.slice(0, space) : cut).trimEnd()}...`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The first sentence of a turn, without fenced code, diff lines or the
|
|
100
|
+
// companion's own framing, collapsed to one line.
|
|
101
|
+
export function firstSentence(text) {
|
|
102
|
+
let body = String(text || '');
|
|
103
|
+
const marker = body.lastIndexOf("The student's message:\n");
|
|
104
|
+
if (marker !== -1) body = body.slice(marker + "The student's message:\n".length);
|
|
105
|
+
body = body.replace(/```[\s\S]*?(```|$)/g, ' ');
|
|
106
|
+
body = body
|
|
107
|
+
.split('\n')
|
|
108
|
+
.filter((line) => !/^\s*[+-]\s*\d*\s*\|/.test(line) && !/^\s*\.\.\.\s*$/.test(line))
|
|
109
|
+
.join(' ');
|
|
110
|
+
body = body.replace(/\s+/g, ' ').trim();
|
|
111
|
+
const match = body.match(/^(.+?[.!?])(\s|$)/);
|
|
112
|
+
return match ? match[1] : body;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The deterministic fallback: the earlier summary (cut to make room) plus
|
|
116
|
+
// one line per dropped exchange, newest kept when it is all too long.
|
|
117
|
+
export function digestSummary({ existing = null, dropped, maxChars }) {
|
|
118
|
+
const lines = dropped.map(
|
|
119
|
+
({ student, tutor }) =>
|
|
120
|
+
`- Student: ${clampText(firstSentence(student), DIGEST_STUDENT_CHARS) || '(code only)'} ` +
|
|
121
|
+
`Tutor: ${clampText(firstSentence(tutor), DIGEST_TUTOR_CHARS) || '(no text)'}`
|
|
122
|
+
);
|
|
123
|
+
while (lines.length > 1 && lines.join('\n').length > maxChars) lines.shift();
|
|
124
|
+
let text = clampText(lines.join('\n'), maxChars);
|
|
125
|
+
if (existing) {
|
|
126
|
+
const room = maxChars - text.length - 1;
|
|
127
|
+
if (room >= 40) text = `${clampText(existing, room)}\n${text}`;
|
|
128
|
+
}
|
|
129
|
+
return text;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The summary call's messages: the instruction, the earlier summary if any,
|
|
133
|
+
// and only the exchanges being dropped.
|
|
134
|
+
export function buildSummaryRequest({ existing = null, dropped }) {
|
|
135
|
+
const exchanges = dropped.map(({ student, tutor }) => `Student: ${student}\n\nTutor: ${tutor}`).join('\n\n---\n\n');
|
|
136
|
+
const content =
|
|
137
|
+
'Summarize these earlier exchanges in under 250 words, keeping what the student tried, mistakes found, and ' +
|
|
138
|
+
'hints already given. ' +
|
|
139
|
+
(existing ? 'Merge in the earlier summary below so one summary covers everything. ' : '') +
|
|
140
|
+
'Reply with the summary only.\n\n' +
|
|
141
|
+
(existing ? `Earlier summary:\n${existing}\n\n` : '') +
|
|
142
|
+
`Exchanges:\n\n${exchanges}`;
|
|
143
|
+
return [
|
|
144
|
+
{ role: 'system', content: SUMMARIZER_SYSTEM },
|
|
145
|
+
{ role: 'user', content },
|
|
146
|
+
];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function withTimeout(promiseFactory, timeoutMs) {
|
|
150
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
|
151
|
+
let timer;
|
|
152
|
+
const timeout = new Promise((_, reject) => {
|
|
153
|
+
timer = setTimeout(() => {
|
|
154
|
+
controller?.abort();
|
|
155
|
+
reject(new Error('the summary call timed out'));
|
|
156
|
+
}, timeoutMs);
|
|
157
|
+
});
|
|
158
|
+
return Promise.race([Promise.resolve().then(() => promiseFactory(controller?.signal)), timeout]).finally(() =>
|
|
159
|
+
clearTimeout(timer)
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Produces the merged summary of `dropped` (and `existing`), at most
|
|
164
|
+
// `maxChars`. Tries `summarize(messages, signal)` once when given and when
|
|
165
|
+
// `fits(messages)` says the call itself fits; otherwise, or when it throws,
|
|
166
|
+
// times out or returns nothing, uses the digest. Returns { summary, source }
|
|
167
|
+
// with source 'model' or 'digest'. Never throws.
|
|
168
|
+
export async function condense({ existing = null, dropped, maxChars, summarize = null, fits = () => true, timeoutMs = SUMMARY_TIMEOUT_MS }) {
|
|
169
|
+
if (summarize) {
|
|
170
|
+
const messages = buildSummaryRequest({ existing, dropped });
|
|
171
|
+
if (fits(messages)) {
|
|
172
|
+
try {
|
|
173
|
+
const reply = await withTimeout((signal) => summarize(messages, signal), timeoutMs);
|
|
174
|
+
const text = clampText(typeof reply === 'string' ? reply : '', maxChars);
|
|
175
|
+
if (text) return { summary: text, source: 'model' };
|
|
176
|
+
} catch {
|
|
177
|
+
// the digest below
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { summary: digestSummary({ existing, dropped, maxChars }), source: 'digest' };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// How many of `pairs` exchanges to condense once some must go: at least
|
|
185
|
+
// `needed`, and at least the older half, so the summary call is not paid
|
|
186
|
+
// again on the very next message.
|
|
187
|
+
export function condenseCount(pairs, needed) {
|
|
188
|
+
return Math.min(pairs, Math.max(needed, Math.ceil(pairs / 2)));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Fits one LocalBackend request to `budget`, condensing instead of dropping.
|
|
192
|
+
// `history` is [system, (user, assistant)*, current]; `summary` the current
|
|
193
|
+
// summary or null; `maxPairs` the exchange-count cap (0 for none) and
|
|
194
|
+
// `forcePairs` exchanges that must go regardless (after a reply cut off for
|
|
195
|
+
// room). Returns { history, summary, messages, estimatedTokens, fits,
|
|
196
|
+
// droppedPairs, summarized ('model' | 'digest' | null), parts? } in the
|
|
197
|
+
// shape of fitToBudget. Never mutates `history`.
|
|
198
|
+
export async function fitWithSummary({
|
|
199
|
+
history,
|
|
200
|
+
pinnedProblemContext = null,
|
|
201
|
+
pinnedCode = null,
|
|
202
|
+
summary = null,
|
|
203
|
+
budget,
|
|
204
|
+
maxPairs = 0,
|
|
205
|
+
forcePairs = 0,
|
|
206
|
+
summarize = null,
|
|
207
|
+
timeoutMs = SUMMARY_TIMEOUT_MS,
|
|
208
|
+
}) {
|
|
209
|
+
const build = (h, s) => buildMessages({ history: h, pinnedProblemContext, pinnedCode, summary: s });
|
|
210
|
+
const pairs = countPairs(history);
|
|
211
|
+
const countExcess = maxPairs > 0 ? Math.max(0, pairs - maxPairs) : 0;
|
|
212
|
+
const mustDrop = Math.min(pairs, Math.max(countExcess, forcePairs));
|
|
213
|
+
const asIs = build(history, summary);
|
|
214
|
+
const asIsTokens = estimateMessagesTokens(asIs);
|
|
215
|
+
if (mustDrop === 0 && asIsTokens <= budget) {
|
|
216
|
+
return { history, summary, messages: asIs, estimatedTokens: asIsTokens, fits: true, droppedPairs: 0, summarized: null };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// The fewest exchanges to condense so the rest plus a full-size summary
|
|
220
|
+
// fit; failing that, all of them with whatever room is left.
|
|
221
|
+
const reserve = estimateTokens(summaryContent('x'.repeat(summaryBodyChars(SUMMARY_MAX_TOKENS))));
|
|
222
|
+
let needed = null;
|
|
223
|
+
for (let k = Math.max(mustDrop, 1); k <= pairs; k += 1) {
|
|
224
|
+
if (estimateMessagesTokens(build(withoutPairs(history, k), null)) + reserve <= budget) {
|
|
225
|
+
needed = k;
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
let k;
|
|
230
|
+
let maxChars;
|
|
231
|
+
if (needed !== null) {
|
|
232
|
+
k = condenseCount(pairs, needed);
|
|
233
|
+
maxChars = summaryBodyChars(SUMMARY_MAX_TOKENS);
|
|
234
|
+
} else {
|
|
235
|
+
k = pairs;
|
|
236
|
+
const room = budget - estimateMessagesTokens(build(withoutPairs(history, pairs), null));
|
|
237
|
+
maxChars = summaryBodyChars(Math.min(SUMMARY_MAX_TOKENS, room));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if ((k > 0 || summary) && maxChars >= summaryBodyChars(SUMMARY_MIN_TOKENS)) {
|
|
241
|
+
const kept = withoutPairs(history, k);
|
|
242
|
+
const dropped = renderedPairs(history, k, pinnedCode);
|
|
243
|
+
const condensed = await condense({
|
|
244
|
+
existing: summary,
|
|
245
|
+
dropped,
|
|
246
|
+
maxChars,
|
|
247
|
+
summarize,
|
|
248
|
+
fits: (messages) => estimateMessagesTokens(messages) <= budget,
|
|
249
|
+
timeoutMs,
|
|
250
|
+
});
|
|
251
|
+
const messages = build(kept, condensed.summary);
|
|
252
|
+
const estimatedTokens = estimateMessagesTokens(messages);
|
|
253
|
+
if (estimatedTokens <= budget) {
|
|
254
|
+
return {
|
|
255
|
+
history: kept,
|
|
256
|
+
summary: condensed.summary,
|
|
257
|
+
messages,
|
|
258
|
+
estimatedTokens,
|
|
259
|
+
fits: true,
|
|
260
|
+
droppedPairs: k,
|
|
261
|
+
summarized: condensed.source,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// No room for any summary: drop the oldest exchanges as before (and the
|
|
267
|
+
// summary with them), or refuse when the pins and this message alone are
|
|
268
|
+
// over the budget.
|
|
269
|
+
const plain = fitToBudget({ history: withoutPairs(history, mustDrop), pinnedProblemContext, pinnedCode, budget });
|
|
270
|
+
return { ...plain, droppedPairs: plain.droppedPairs + mustDrop, summary: null, summarized: null };
|
|
271
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// extension/shared/conversation.js - the tutor conversation as data every
|
|
2
|
+
// backend can hand to another, and the flattening the prompt-only backends
|
|
3
|
+
// (CodexBackend, ClaudeBackend's rebuild) share.
|
|
4
|
+
//
|
|
5
|
+
// Every backend keeps the same three things about the chat: the exchanges
|
|
6
|
+
// still shown in full, the "earlier in this session" summary of condensed
|
|
7
|
+
// ones (context-summary.js) and the pinned problem statement. The student's
|
|
8
|
+
// code is not part of it: the runtime's code ledger (code-ledger.js) owns it
|
|
9
|
+
// and hands every request the current full code. So a backend swap (another
|
|
10
|
+
// model on the options page, or the model switcher) moves this state to the
|
|
11
|
+
// new backend and the tutor forgets nothing: `transferConversation`.
|
|
12
|
+
//
|
|
13
|
+
// The exported shape, the same from every backend:
|
|
14
|
+
// { turns: [{ role: 'user' | 'assistant', content, compactContent? }],
|
|
15
|
+
// summary: string | null,
|
|
16
|
+
// problem: { problemId, title, slug, description } | null }
|
|
17
|
+
// `turns` always holds whole exchanges, oldest first.
|
|
18
|
+
//
|
|
19
|
+
// Pure JavaScript with no Node API (extension/shared.test.js checks that).
|
|
20
|
+
|
|
21
|
+
// A copy of `state` in the exported shape, with anything malformed left out:
|
|
22
|
+
// a turn that is not a string, a trailing unanswered user turn, a role out of
|
|
23
|
+
// order. Never throws.
|
|
24
|
+
export function normalizeConversation(state) {
|
|
25
|
+
const turns = [];
|
|
26
|
+
const raw = Array.isArray(state?.turns) ? state.turns : [];
|
|
27
|
+
for (const turn of raw) {
|
|
28
|
+
if (!turn || typeof turn.content !== 'string') continue;
|
|
29
|
+
const role = turn.role === 'assistant' ? 'assistant' : turn.role === 'user' ? 'user' : null;
|
|
30
|
+
const expected = turns.length % 2 === 0 ? 'user' : 'assistant';
|
|
31
|
+
if (role !== expected) continue;
|
|
32
|
+
turns.push({
|
|
33
|
+
role,
|
|
34
|
+
content: turn.content,
|
|
35
|
+
...(typeof turn.compactContent === 'string' && turn.compactContent !== turn.content
|
|
36
|
+
? { compactContent: turn.compactContent }
|
|
37
|
+
: {}),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
if (turns.length % 2 === 1) turns.pop();
|
|
41
|
+
const summary = typeof state?.summary === 'string' && state.summary ? state.summary : null;
|
|
42
|
+
const problem = state?.problem && typeof state.problem === 'object' ? { ...state.problem } : null;
|
|
43
|
+
return { turns, summary, problem };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Moves the conversation from one backend to another. Returns true when it
|
|
47
|
+
// did (both sides support it and they differ), so the caller knows the new
|
|
48
|
+
// backend has seen everything the old one had.
|
|
49
|
+
export function transferConversation(from, to) {
|
|
50
|
+
if (!from || !to || from === to) return false;
|
|
51
|
+
if (typeof from.exportConversation !== 'function' || typeof to.importConversation !== 'function') return false;
|
|
52
|
+
to.importConversation(normalizeConversation(from.exportConversation()));
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Splits buildMessages' list (context-budget.js) for a backend that takes one
|
|
57
|
+
// system text and one prompt string: the first `systemCount` leading system
|
|
58
|
+
// messages become the system text (keep it to what never changes within a
|
|
59
|
+
// problem, so the provider can cache it), and everything else becomes the
|
|
60
|
+
// prompt, in order: any further system messages (the pinned code, the
|
|
61
|
+
// summary), the earlier exchanges as a transcript, then the current message
|
|
62
|
+
// and `closing`.
|
|
63
|
+
export function flattenMessages(messages, { systemCount = Infinity, closing = '' } = {}) {
|
|
64
|
+
const leading = [];
|
|
65
|
+
let i = 0;
|
|
66
|
+
while (i < messages.length && messages[i].role === 'system') {
|
|
67
|
+
leading.push(messages[i].content);
|
|
68
|
+
i += 1;
|
|
69
|
+
}
|
|
70
|
+
const turns = messages.slice(i);
|
|
71
|
+
const current = turns.pop();
|
|
72
|
+
const system = leading.slice(0, systemCount);
|
|
73
|
+
const parts = leading.slice(systemCount);
|
|
74
|
+
if (turns.length) {
|
|
75
|
+
parts.push(
|
|
76
|
+
`Conversation so far:\n\n${turns.map((m) => `${m.role === 'user' ? 'Student' : 'Tutor'}: ${m.content}`).join('\n\n')}`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
parts.push(`Student: ${current ? current.content : ''}${closing ? `\n\n${closing}` : ''}`);
|
|
80
|
+
return { system: system.join('\n\n---\n\n'), prompt: parts.join('\n\n---\n\n') };
|
|
81
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// extension/shared/event-hub.js - the in-extension stand-in for the relay's
|
|
2
|
+
// panel broker (relay-server/panel-broker.js): a bounded, numbered buffer of
|
|
3
|
+
// tutor events that every Tutor panel replays on connect and then follows
|
|
4
|
+
// live. The background worker owns one; tutor-engine.js publishes into it.
|
|
5
|
+
//
|
|
6
|
+
// It keeps the relay's event protocol exactly, so the panel applies these
|
|
7
|
+
// events with the same code either way (see extension/panel.js):
|
|
8
|
+
// - every event carries `seq` and the hub's random `epoch`; a panel that
|
|
9
|
+
// reconnects with `after` and the same epoch gets only what it missed,
|
|
10
|
+
// and one from another epoch (the worker restarted) gets everything;
|
|
11
|
+
// - a turn-end drops that turn's buffered chunks, since it carries the
|
|
12
|
+
// whole text;
|
|
13
|
+
// - after the replay a subscriber gets the newest `config` event, then a
|
|
14
|
+
// `status` event ({ companionOnline, busy, epoch, openTurns });
|
|
15
|
+
// - dropProblem forgets a problem's events (the reset on a problem switch).
|
|
16
|
+
//
|
|
17
|
+
// Pure JavaScript with no Node API (extension/shared.test.js checks that).
|
|
18
|
+
|
|
19
|
+
export const MAX_BUFFERED_EVENTS = 2000;
|
|
20
|
+
export const MAX_OPEN_TURNS = 100;
|
|
21
|
+
|
|
22
|
+
function randomEpoch() {
|
|
23
|
+
const bytes = new Uint8Array(8);
|
|
24
|
+
crypto.getRandomValues(bytes);
|
|
25
|
+
return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createEventHub({ epoch = randomEpoch(), maxEvents = MAX_BUFFERED_EVENTS } = {}) {
|
|
29
|
+
let nextSeq = 1;
|
|
30
|
+
let events = [];
|
|
31
|
+
const subscribers = new Set();
|
|
32
|
+
const openTurns = new Map(); // turnId -> problemSlug
|
|
33
|
+
let busy = false;
|
|
34
|
+
let config = null;
|
|
35
|
+
|
|
36
|
+
function send(subscriber, event) {
|
|
37
|
+
try {
|
|
38
|
+
subscriber(event);
|
|
39
|
+
} catch {
|
|
40
|
+
// A subscriber whose port went away; its own disconnect handler
|
|
41
|
+
// unsubscribes it.
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function statusEvent() {
|
|
46
|
+
return { type: 'status', companionOnline: true, busy, epoch, openTurns: [...openTurns.keys()] };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function broadcast(event) {
|
|
50
|
+
for (const subscriber of subscribers) send(subscriber, event);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function track(event) {
|
|
54
|
+
if (!event.turnId) return;
|
|
55
|
+
if (event.type === 'turn-start') {
|
|
56
|
+
openTurns.delete(event.turnId);
|
|
57
|
+
openTurns.set(event.turnId, event.problemSlug || null);
|
|
58
|
+
if (openTurns.size > MAX_OPEN_TURNS) openTurns.delete(openTurns.keys().next().value);
|
|
59
|
+
} else if (event.type === 'turn-end') {
|
|
60
|
+
openTurns.delete(event.turnId);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
epoch,
|
|
66
|
+
|
|
67
|
+
// Adds one event ({ type, problemSlug?, turnId?, text?, ... }) and sends
|
|
68
|
+
// it to every subscriber. Returns the stored event.
|
|
69
|
+
publish(event) {
|
|
70
|
+
track(event);
|
|
71
|
+
const stored = { ...event, seq: nextSeq++, epoch };
|
|
72
|
+
if (stored.type === 'turn-end' && stored.turnId) {
|
|
73
|
+
events = events.filter((e) => !(e.type === 'chunk' && e.turnId === stored.turnId));
|
|
74
|
+
}
|
|
75
|
+
events.push(stored);
|
|
76
|
+
if (events.length > maxEvents) events = events.slice(events.length - maxEvents);
|
|
77
|
+
broadcast(stored);
|
|
78
|
+
return stored;
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
// Forgets every buffered event of one problem, chunks of its turns too
|
|
82
|
+
// (they carry only a turn id).
|
|
83
|
+
dropProblem(slug) {
|
|
84
|
+
if (!slug) return 0;
|
|
85
|
+
const before = events.length;
|
|
86
|
+
const turnIds = new Set(events.filter((e) => e.problemSlug === slug && e.turnId).map((e) => e.turnId));
|
|
87
|
+
events = events.filter((e) => e.problemSlug !== slug && !(e.turnId && turnIds.has(e.turnId)));
|
|
88
|
+
for (const [turnId, problemSlug] of openTurns) if (problemSlug === slug) openTurns.delete(turnId);
|
|
89
|
+
return before - events.length;
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
setBusy(next) {
|
|
93
|
+
if (busy === Boolean(next)) return;
|
|
94
|
+
busy = Boolean(next);
|
|
95
|
+
broadcast(statusEvent());
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
// The non-secret summary of what the tutor runs on ({ provider, label,
|
|
99
|
+
// model }), for the panel header.
|
|
100
|
+
setConfig(next) {
|
|
101
|
+
config = next ? { type: 'config', provider: next.provider, label: next.label, model: next.model ?? null } : null;
|
|
102
|
+
if (config) broadcast(config);
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
// Replays the buffer after `after` (everything when `clientEpoch` is from
|
|
106
|
+
// another run), then config and status, then follows live. Returns the
|
|
107
|
+
// unsubscribe function.
|
|
108
|
+
subscribe(subscriber, { after = 0, epoch: clientEpoch = null } = {}) {
|
|
109
|
+
const from = clientEpoch && clientEpoch !== epoch ? 0 : Number.isFinite(after) ? after : 0;
|
|
110
|
+
for (const event of events) if (event.seq > from) send(subscriber, event);
|
|
111
|
+
if (config) send(subscriber, config);
|
|
112
|
+
send(subscriber, statusEvent());
|
|
113
|
+
subscribers.add(subscriber);
|
|
114
|
+
return () => subscribers.delete(subscriber);
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
subscriberCount() {
|
|
118
|
+
return subscribers.size;
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
// For tests.
|
|
122
|
+
events() {
|
|
123
|
+
return events.slice();
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|