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.
- package/cli.js +92 -0
- package/config.js +25 -0
- package/framing.js +57 -0
- package/host.js +420 -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 +2190 -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 +168 -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 +1337 -0
- package/runtime/extension/shared/sanitize.js +68 -0
- package/runtime/extension/shared/solved.js +316 -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,333 @@
|
|
|
1
|
+
// extension/shared/code-ledger.js - what code the tutor has already been shown, and
|
|
2
|
+
// what to tell it next.
|
|
3
|
+
//
|
|
4
|
+
// The rules (Thomson's request):
|
|
5
|
+
// - A Run (the button or Ctrl+') is never sent to the tutor on its own. It
|
|
6
|
+
// is only recorded, as the newest code for its problem.
|
|
7
|
+
// - A Submit is sent automatically, but only when its code differs from
|
|
8
|
+
// what the tutor already holds.
|
|
9
|
+
// - A typed message carries the code only when it changed since the tutor
|
|
10
|
+
// last saw it, and then as a diff against that version, not the whole
|
|
11
|
+
// code again. The first time (or after a reset) the tutor holds nothing,
|
|
12
|
+
// so the full code goes once; a diff that would be nearly as long as the
|
|
13
|
+
// code itself is replaced by the full code too.
|
|
14
|
+
// - The active problem is whichever one the student last sent a message,
|
|
15
|
+
// Run or Submit from. Activity on a different problem resets the tutor:
|
|
16
|
+
// the old problem's entry here is dropped and no problem's code counts
|
|
17
|
+
// as held any more, because the tutor's context is wiped with it.
|
|
18
|
+
//
|
|
19
|
+
// "Held" code (the baseline every diff is against) only advances through
|
|
20
|
+
// commit(), which the caller makes once the backend call succeeded, so a
|
|
21
|
+
// failed call never makes the ledger believe the tutor saw code it did not.
|
|
22
|
+
//
|
|
23
|
+
// Pure JavaScript with no Node API (no fs, http, process or Buffer), so the
|
|
24
|
+
// same module can later run inside the browser extension. All I/O stays in
|
|
25
|
+
// companion.js.
|
|
26
|
+
|
|
27
|
+
import { formatDiffBody } from './context-budget.js';
|
|
28
|
+
|
|
29
|
+
// A backstop on remembered problems: a reset already drops the old problem,
|
|
30
|
+
// so this only bounds the case where resets are turned off.
|
|
31
|
+
export const DEFAULT_MAX_PROBLEMS = 20;
|
|
32
|
+
// A diff longer than this share of the code it describes saves too little to
|
|
33
|
+
// be worth the model applying it; the full code goes instead.
|
|
34
|
+
export const FULL_CODE_RATIO = 0.8;
|
|
35
|
+
// Unchanged lines shown around each change, so the tutor can place it.
|
|
36
|
+
export const DIFF_CONTEXT_LINES = 2;
|
|
37
|
+
// Upper bound on the diff's table for the changed region (see diffLines'
|
|
38
|
+
// maxCells): about 2,000 by 2,000 changed lines. Past it the full code goes.
|
|
39
|
+
export const MAX_DIFF_CELLS = 4_000_000;
|
|
40
|
+
|
|
41
|
+
// What every stateless request labels the pinned current code with (see
|
|
42
|
+
// formatPinnedCode).
|
|
43
|
+
export const CURRENT_CODE_LABEL = "The student's current code";
|
|
44
|
+
|
|
45
|
+
// Shown to the model in place of an older turn's full code, since every
|
|
46
|
+
// request pins the current code (see context-budget.js's buildMessages).
|
|
47
|
+
export const FULL_CODE_COMPACT_NOTE =
|
|
48
|
+
`(The student's full code was shown here. Their current code is pinned at the top of the conversation as "${CURRENT_CODE_LABEL}".)`;
|
|
49
|
+
|
|
50
|
+
// Only a Submit is sent to the tutor by itself. Anything else, a Run or an
|
|
51
|
+
// unknown trigger (the relay's /capture route accepts posts from any page),
|
|
52
|
+
// is only recorded.
|
|
53
|
+
export function sendsToTutor(trigger) {
|
|
54
|
+
return trigger === 'submit';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function languageFenceHint(language) {
|
|
58
|
+
const known = {
|
|
59
|
+
python: 'python',
|
|
60
|
+
python3: 'python',
|
|
61
|
+
javascript: 'javascript',
|
|
62
|
+
typescript: 'typescript',
|
|
63
|
+
java: 'java',
|
|
64
|
+
'c++': 'cpp',
|
|
65
|
+
cpp: 'cpp',
|
|
66
|
+
c: 'c',
|
|
67
|
+
'c#': 'csharp',
|
|
68
|
+
go: 'go',
|
|
69
|
+
golang: 'go',
|
|
70
|
+
rust: 'rust',
|
|
71
|
+
kotlin: 'kotlin',
|
|
72
|
+
swift: 'swift',
|
|
73
|
+
ruby: 'ruby',
|
|
74
|
+
scala: 'scala',
|
|
75
|
+
php: 'php',
|
|
76
|
+
};
|
|
77
|
+
return known[String(language).toLowerCase()] || '';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function fenced(code, language) {
|
|
81
|
+
return `\`\`\`${languageFenceHint(language)}\n${code}\n\`\`\``;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function languageName(language) {
|
|
85
|
+
return language || 'unknown language';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function createCodeLedger({ maxProblems = DEFAULT_MAX_PROBLEMS } = {}) {
|
|
89
|
+
// problemId -> { slug, context, latest, held }, least recently used first.
|
|
90
|
+
// context: { title, slug, description } from the newest capture or snapshot
|
|
91
|
+
// latest: { code, language } the newest code seen from any source
|
|
92
|
+
// held: { code, language } what the tutor has been shown, or null
|
|
93
|
+
const problems = new Map();
|
|
94
|
+
let activeId = null;
|
|
95
|
+
|
|
96
|
+
function touch(problemId) {
|
|
97
|
+
let entry = problems.get(problemId);
|
|
98
|
+
if (entry) problems.delete(problemId);
|
|
99
|
+
else entry = { slug: null, context: null, latest: null, held: null };
|
|
100
|
+
problems.set(problemId, entry);
|
|
101
|
+
for (const id of problems.keys()) {
|
|
102
|
+
if (problems.size <= maxProblems) break;
|
|
103
|
+
if (id !== problemId && id !== activeId) problems.delete(id);
|
|
104
|
+
}
|
|
105
|
+
return entry;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
get activeProblemId() {
|
|
110
|
+
return activeId;
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
// Makes problemId the active problem. When another problem was active and
|
|
114
|
+
// resetOnSwitch is on, that problem is dropped and no code counts as held
|
|
115
|
+
// any more: the caller wipes the tutor's context to match. `dropped`
|
|
116
|
+
// names the old problem (and its slug, when it had one) so the caller can
|
|
117
|
+
// clear what else it keeps about it.
|
|
118
|
+
activate(problemId, { slug = null, resetOnSwitch = true } = {}) {
|
|
119
|
+
if (!problemId) return { switched: false, reset: false, previousId: activeId, dropped: null };
|
|
120
|
+
const previousId = activeId;
|
|
121
|
+
const switched = previousId !== null && previousId !== problemId;
|
|
122
|
+
let dropped = null;
|
|
123
|
+
activeId = problemId;
|
|
124
|
+
if (switched && resetOnSwitch) {
|
|
125
|
+
const old = problems.get(previousId);
|
|
126
|
+
problems.delete(previousId);
|
|
127
|
+
dropped = { problemId: previousId, slug: old ? old.slug : null };
|
|
128
|
+
for (const entry of problems.values()) entry.held = null;
|
|
129
|
+
}
|
|
130
|
+
const entry = touch(problemId);
|
|
131
|
+
if (slug) entry.slug = slug;
|
|
132
|
+
return { switched, reset: switched && resetOnSwitch, previousId, dropped };
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
// Records the newest code for a problem (a Run, a Submit, or the live
|
|
136
|
+
// editor read at send time). `context` is the problem statement that came
|
|
137
|
+
// with it; a later source without a description keeps the one known.
|
|
138
|
+
record(problemId, { code, language = null, slug = null, context = null } = {}) {
|
|
139
|
+
if (!problemId) return;
|
|
140
|
+
const entry = touch(problemId);
|
|
141
|
+
if (slug) entry.slug = slug;
|
|
142
|
+
if (typeof code === 'string') entry.latest = { code, language: language || null };
|
|
143
|
+
if (context) {
|
|
144
|
+
const description = context.description || (entry.context && entry.context.description) || null;
|
|
145
|
+
entry.context = { ...context, description };
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
latest(problemId) {
|
|
150
|
+
const entry = problems.get(problemId);
|
|
151
|
+
return entry ? entry.latest : null;
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
context(problemId) {
|
|
155
|
+
const entry = problems.get(problemId);
|
|
156
|
+
return entry ? entry.context : null;
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
held(problemId) {
|
|
160
|
+
const entry = problems.get(problemId);
|
|
161
|
+
return entry ? entry.held : null;
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
has(problemId) {
|
|
165
|
+
return problems.has(problemId);
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
get size() {
|
|
169
|
+
return problems.size;
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
// What the tutor needs to be told about this problem's code, without
|
|
173
|
+
// changing anything: `none` (no code known), `unchanged` (the tutor holds
|
|
174
|
+
// exactly this), `full` or `diff`. `current` defaults to the newest code
|
|
175
|
+
// recorded.
|
|
176
|
+
planUpdate(problemId, current = undefined) {
|
|
177
|
+
const entry = problems.get(problemId);
|
|
178
|
+
const code = current === undefined ? entry && entry.latest : current;
|
|
179
|
+
if (!code || typeof code.code !== 'string') return { kind: 'none' };
|
|
180
|
+
const held = entry ? entry.held : null;
|
|
181
|
+
const language = code.language || (held && held.language) || null;
|
|
182
|
+
if (held && held.code === code.code && (held.language || null) === language) {
|
|
183
|
+
return { kind: 'unchanged', code: code.code, language };
|
|
184
|
+
}
|
|
185
|
+
const full = { kind: 'full', code: code.code, language };
|
|
186
|
+
// A language switch replaces the whole solution, and a diff against
|
|
187
|
+
// code in another language reads as nonsense.
|
|
188
|
+
if (!held || (held.language || null) !== language) return full;
|
|
189
|
+
const diff = formatDiffBody(held.code, code.code, {
|
|
190
|
+
context: DIFF_CONTEXT_LINES,
|
|
191
|
+
lineNumbers: true,
|
|
192
|
+
maxCells: MAX_DIFF_CELLS,
|
|
193
|
+
});
|
|
194
|
+
if (!diff || diff.body.length > FULL_CODE_RATIO * code.code.length) return full;
|
|
195
|
+
return { kind: 'diff', code: code.code, language, diff: diff.body, added: diff.added, removed: diff.removed };
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
// The backend call that carried this code succeeded: the tutor holds it.
|
|
199
|
+
commit(problemId, { code, language = null }) {
|
|
200
|
+
if (!problemId || typeof code !== 'string') return;
|
|
201
|
+
touch(problemId).held = { code, language: language || null };
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
// The tutor's context was wiped some other way: it holds no code.
|
|
205
|
+
forgetHeld() {
|
|
206
|
+
for (const entry of problems.values()) entry.held = null;
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// The code part of a message for a plan from planUpdate, or null when there
|
|
212
|
+
// is nothing to show (`none` or `unchanged`). A `full` plan carries the code
|
|
213
|
+
// (a resumed Claude session has no pinned copy); a `diff` the numbered diff,
|
|
214
|
+
// which the pinned full code alone cannot show: what the student just changed.
|
|
215
|
+
export function formatCodeUpdate(plan) {
|
|
216
|
+
if (plan.kind === 'full') {
|
|
217
|
+
return `The student's current code (${languageName(plan.language)}), in full:\n\n${fenced(plan.code, plan.language)}`;
|
|
218
|
+
}
|
|
219
|
+
if (plan.kind === 'diff') {
|
|
220
|
+
return (
|
|
221
|
+
`Changes to the student's code since the version you last saw ` +
|
|
222
|
+
`(${languageName(plan.language)}, +${plan.added}/-${plan.removed} lines; the line numbers are those of ` +
|
|
223
|
+
`the current code):\n\n` +
|
|
224
|
+
`\`\`\`diff\n${plan.diff}\n\`\`\``
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// The block holding the student's current full code, which every request
|
|
231
|
+
// carries (see context-budget.js's buildMessages, and ClaudeBackend's
|
|
232
|
+
// session path), or null when no code is known. `code.stale` marks the last
|
|
233
|
+
// version the tutor was shown, standing in when the editor could not be read.
|
|
234
|
+
export function formatPinnedCode(code) {
|
|
235
|
+
if (!code || typeof code.code !== 'string' || code.inMessage) return null;
|
|
236
|
+
const language = languageName(code.language);
|
|
237
|
+
const header = code.stale
|
|
238
|
+
? `${CURRENT_CODE_LABEL} (${language}), as last read: their editor could not be read for the newest ` +
|
|
239
|
+
'message, so this is the last version you were shown and it may be out of date.'
|
|
240
|
+
: `${CURRENT_CODE_LABEL} (${language}), exactly as it is now. Every code change shown in the conversation ` +
|
|
241
|
+
'is already applied here; reason about this version.';
|
|
242
|
+
return `${header}\n\n${fenced(code.code, code.language)}`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// The code a turn's request carries: the code the plan sends (full, diff or
|
|
246
|
+
// unchanged), else the last version the tutor was shown (marked stale when
|
|
247
|
+
// the editor could not be read), else null. A `full` plan's message already
|
|
248
|
+
// holds the whole code, so it is marked `inMessage` and not pinned a second
|
|
249
|
+
// time on that request (formatPinnedCode); every later request pins it.
|
|
250
|
+
export function currentCodeFor(plan, held, { codeUnavailable = false } = {}) {
|
|
251
|
+
if (plan && typeof plan.code === 'string' && (plan.kind === 'full' || plan.kind === 'diff' || plan.kind === 'unchanged')) {
|
|
252
|
+
return { code: plan.code, language: plan.language || null, stale: false, ...(plan.kind === 'full' ? { inMessage: true } : {}) };
|
|
253
|
+
}
|
|
254
|
+
if (held && typeof held.code === 'string') return { code: held.code, language: held.language || null, stale: Boolean(codeUnavailable) };
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// A Submit: its header line, the code update (full or diff), then the
|
|
259
|
+
// instruction for how to answer. `compactText` is what an older copy of this
|
|
260
|
+
// turn becomes once its code is pinned instead: no full code, no instruction.
|
|
261
|
+
export function buildSubmitMessage({ headerLine, plan, addendum = '' }) {
|
|
262
|
+
const update = formatCodeUpdate(plan);
|
|
263
|
+
const text = update ? `${headerLine}\n\n${update}${addendum}` : `${headerLine}${addendum}`;
|
|
264
|
+
let compactText = text;
|
|
265
|
+
if (plan.kind === 'full') compactText = `${headerLine}\n\n${FULL_CODE_COMPACT_NOTE}`;
|
|
266
|
+
else if (update) compactText = `${headerLine}\n\n${update}`;
|
|
267
|
+
return { text, compactText };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// A typed message: an optional preamble (which problem the student is on),
|
|
271
|
+
// the code update when the code changed, then what they wrote. With neither,
|
|
272
|
+
// the message goes exactly as typed.
|
|
273
|
+
export function buildChatMessage({ text, plan = { kind: 'none' }, preamble = null }) {
|
|
274
|
+
const update = formatCodeUpdate(plan);
|
|
275
|
+
const parts = [];
|
|
276
|
+
const compactParts = [];
|
|
277
|
+
if (preamble) {
|
|
278
|
+
parts.push(preamble);
|
|
279
|
+
compactParts.push(preamble);
|
|
280
|
+
}
|
|
281
|
+
if (update) {
|
|
282
|
+
parts.push(update);
|
|
283
|
+
compactParts.push(plan.kind === 'full' ? FULL_CODE_COMPACT_NOTE : update);
|
|
284
|
+
}
|
|
285
|
+
if (parts.length === 0) return { text, compactText: text };
|
|
286
|
+
const message = update ? `The student's message:\n${text}` : text;
|
|
287
|
+
parts.push(message);
|
|
288
|
+
compactParts.push(message);
|
|
289
|
+
return { text: parts.join('\n\n'), compactText: compactParts.join('\n\n') };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// The notes a panel message leads with: the problem the student moved to,
|
|
293
|
+
// and why their code is missing when the editor could not be read.
|
|
294
|
+
export function chatPreamble({ previousId, problemId, codeUnavailable = false, hasHeld = false }) {
|
|
295
|
+
const notes = [];
|
|
296
|
+
if (problemId && previousId !== problemId) notes.push(`(The student is now working on the LeetCode problem "${problemId}".)`);
|
|
297
|
+
if (codeUnavailable) {
|
|
298
|
+
notes.push(
|
|
299
|
+
hasHeld
|
|
300
|
+
? `(The student's editor could not be read, so their newest code is not included: "${CURRENT_CODE_LABEL}" is the last version you were shown and may be out of date.)`
|
|
301
|
+
: "(The student's editor could not be read, so their current code is not included.)"
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
return notes.length ? notes.join('\n') : null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Everything a typed message's turn needs, the same in both runtimes (the
|
|
308
|
+
// companion and the extension's engine): the plan, the message, and the code
|
|
309
|
+
// the request carries. `preamble` is a string, a function of { plan, hasHeld },
|
|
310
|
+
// or null.
|
|
311
|
+
export function prepareChatTurn(ledger, { text, problemId, preamble = null, codeUnavailable = false }) {
|
|
312
|
+
const plan = problemId && !codeUnavailable ? ledger.planUpdate(problemId) : { kind: 'none' };
|
|
313
|
+
const held = problemId ? ledger.held(problemId) : null;
|
|
314
|
+
const lead = typeof preamble === 'function' ? preamble({ plan, hasHeld: Boolean(held) }) : preamble;
|
|
315
|
+
const message = buildChatMessage({ text, plan, preamble: lead });
|
|
316
|
+
return {
|
|
317
|
+
plan,
|
|
318
|
+
message,
|
|
319
|
+
problemContext: (problemId && ledger.context(problemId)) || undefined,
|
|
320
|
+
currentCode: currentCodeFor(plan, held, { codeUnavailable }),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// The same for a Submit the ledger decided to send (plan `full` or `diff`).
|
|
325
|
+
export function prepareSubmitTurn(ledger, { problemId, plan, headerLine, addendum = '', fallbackContext = null }) {
|
|
326
|
+
const message = buildSubmitMessage({ headerLine, plan, addendum });
|
|
327
|
+
return {
|
|
328
|
+
plan,
|
|
329
|
+
message,
|
|
330
|
+
problemContext: ledger.context(problemId) || fallbackContext || undefined,
|
|
331
|
+
currentCode: currentCodeFor(plan, ledger.held(problemId)),
|
|
332
|
+
};
|
|
333
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// extension/shared/context-budget.js - pure helpers for LocalBackend's context-
|
|
2
|
+
// budget management: the pre-flight request-size estimate, the line diff
|
|
3
|
+
// the tutor's code updates are built from (see code-ledger.js), and the
|
|
4
|
+
// outgoing message list with the pinned description and code. Split out
|
|
5
|
+
// from companion.js so this logic - deterministic, no I/O, no readline or
|
|
6
|
+
// backend state - can be unit-tested directly, the same way
|
|
7
|
+
// terminal-format.js and vault-summary.js already are, rather than only
|
|
8
|
+
// reachable through a full companion.js subprocess. It uses no Node API, so
|
|
9
|
+
// it can also run inside the browser extension. See companion.js's
|
|
10
|
+
// LocalBackend class comment and the top-level AGENTS.md for the
|
|
11
|
+
// live-confirmed failures this exists to address.
|
|
12
|
+
|
|
13
|
+
// Cheap character-based token-count estimate, used only for the pre-flight
|
|
14
|
+
// size check in LocalBackend.sendMessage (companion.js) - not a real
|
|
15
|
+
// tokenizer, and not meant to be one. Calibrated against the real, locally-
|
|
16
|
+
// running gemma4:26b model this project is configured for (not assumed):
|
|
17
|
+
// four live /api/chat requests at 30/100/250/500 lines of realistic Python
|
|
18
|
+
// code measured 4.28/4.13/4.01/3.93 characters per prompt token
|
|
19
|
+
// respectively - converging toward ~3.9 as size grows, matching
|
|
20
|
+
// data/companion-context-capacity-test's own finding (in the firstmate
|
|
21
|
+
// repo) almost exactly. 3.9 is used here deliberately as the conservative
|
|
22
|
+
// (lower) end of that measured range: since estimated tokens = length /
|
|
23
|
+
// CHARS_PER_TOKEN_ESTIMATE, a smaller divisor yields a larger (safer, more
|
|
24
|
+
// conservative - more likely to trigger the pre-flight refusal) estimate
|
|
25
|
+
// for the same character count, and the measured ratio is lowest, i.e.
|
|
26
|
+
// closest to 3.9, exactly in the large-capture danger zone this check
|
|
27
|
+
// exists to catch - so this estimate is most accurate right where it
|
|
28
|
+
// matters most.
|
|
29
|
+
export const CHARS_PER_TOKEN_ESTIMATE = 3.9;
|
|
30
|
+
|
|
31
|
+
export function estimateTokens(text) {
|
|
32
|
+
return Math.ceil((text || '').length / CHARS_PER_TOKEN_ESTIMATE);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function estimateMessagesTokens(messages) {
|
|
36
|
+
return messages.reduce((sum, m) => sum + estimateTokens(m.content), 0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// A simple LCS-based line diff (no external dependency - this project's own
|
|
40
|
+
// "no deps" bar, see companion/'s own header comment). The common leading
|
|
41
|
+
// and trailing lines are matched up front, so the quadratic LCS table only
|
|
42
|
+
// covers the region that actually changed: a one-line edit in a 300-line
|
|
43
|
+
// solution costs a tiny table, not a 300 by 300 one. Not meant to be a
|
|
44
|
+
// byte-perfect unified diff, just a correct and compact list of operations.
|
|
45
|
+
// `maxCells` bounds that table: past it this returns null instead of
|
|
46
|
+
// allocating it (a rewrite of a very long file), and the caller sends the
|
|
47
|
+
// whole code instead.
|
|
48
|
+
export function diffLines(oldLines, newLines, { maxCells = Infinity } = {}) {
|
|
49
|
+
let prefix = 0;
|
|
50
|
+
while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) prefix++;
|
|
51
|
+
let suffix = 0;
|
|
52
|
+
while (
|
|
53
|
+
suffix < oldLines.length - prefix &&
|
|
54
|
+
suffix < newLines.length - prefix &&
|
|
55
|
+
oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]
|
|
56
|
+
) {
|
|
57
|
+
suffix++;
|
|
58
|
+
}
|
|
59
|
+
const oldMid = oldLines.slice(prefix, oldLines.length - suffix);
|
|
60
|
+
const newMid = newLines.slice(prefix, newLines.length - suffix);
|
|
61
|
+
const n = oldMid.length;
|
|
62
|
+
const m = newMid.length;
|
|
63
|
+
if ((n + 1) * (m + 1) > maxCells) return null;
|
|
64
|
+
const lcs = Array.from({ length: n + 1 }, () => new Int32Array(m + 1));
|
|
65
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
66
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
67
|
+
lcs[i][j] = oldMid[i] === newMid[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const ops = [];
|
|
71
|
+
for (let k = 0; k < prefix; k++) ops.push({ type: 'same', line: oldLines[k] });
|
|
72
|
+
let i = 0;
|
|
73
|
+
let j = 0;
|
|
74
|
+
while (i < n && j < m) {
|
|
75
|
+
if (oldMid[i] === newMid[j]) {
|
|
76
|
+
ops.push({ type: 'same', line: oldMid[i] });
|
|
77
|
+
i++;
|
|
78
|
+
j++;
|
|
79
|
+
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
|
80
|
+
ops.push({ type: 'del', line: oldMid[i] });
|
|
81
|
+
i++;
|
|
82
|
+
} else {
|
|
83
|
+
ops.push({ type: 'add', line: newMid[j] });
|
|
84
|
+
j++;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
while (i < n) {
|
|
88
|
+
ops.push({ type: 'del', line: oldMid[i] });
|
|
89
|
+
i++;
|
|
90
|
+
}
|
|
91
|
+
while (j < m) {
|
|
92
|
+
ops.push({ type: 'add', line: newMid[j] });
|
|
93
|
+
j++;
|
|
94
|
+
}
|
|
95
|
+
for (let k = oldLines.length - suffix; k < oldLines.length; k++) ops.push({ type: 'same', line: oldLines[k] });
|
|
96
|
+
return ops;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Renders diffLines' ops as +/- lines with `context` unchanged lines around
|
|
100
|
+
// each change, collapsing everything else to "...". With `lineNumbers`, every
|
|
101
|
+
// unchanged or added line also carries its line number in the NEW code (a
|
|
102
|
+
// removed line has none, it no longer exists there), so a reader holding the
|
|
103
|
+
// old code can place each change without a unified diff's hunk headers.
|
|
104
|
+
// Returns the rendered lines plus the added/removed counts, or null when
|
|
105
|
+
// diffLines gave up (see its `maxCells`).
|
|
106
|
+
export function formatDiffBody(oldCode, newCode, { context = 1, lineNumbers = false, maxCells } = {}) {
|
|
107
|
+
const ops = diffLines((oldCode || '').split('\n'), (newCode || '').split('\n'), { maxCells });
|
|
108
|
+
if (!ops) return null;
|
|
109
|
+
const keep = new Array(ops.length).fill(false);
|
|
110
|
+
ops.forEach((op, idx) => {
|
|
111
|
+
if (op.type === 'same') return;
|
|
112
|
+
for (let k = Math.max(0, idx - context); k <= Math.min(ops.length - 1, idx + context); k++) keep[k] = true;
|
|
113
|
+
});
|
|
114
|
+
const newLineCount = ops.filter((op) => op.type !== 'del').length;
|
|
115
|
+
const width = String(Math.max(1, newLineCount)).length;
|
|
116
|
+
const out = [];
|
|
117
|
+
let skipping = false;
|
|
118
|
+
let added = 0;
|
|
119
|
+
let removed = 0;
|
|
120
|
+
let newLineNo = 0;
|
|
121
|
+
ops.forEach((op, idx) => {
|
|
122
|
+
if (op.type !== 'del') newLineNo++;
|
|
123
|
+
if (op.type === 'add') added++;
|
|
124
|
+
if (op.type === 'del') removed++;
|
|
125
|
+
if (!keep[idx]) {
|
|
126
|
+
if (!skipping) {
|
|
127
|
+
out.push('...');
|
|
128
|
+
skipping = true;
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
skipping = false;
|
|
133
|
+
const prefix = op.type === 'add' ? '+' : op.type === 'del' ? '-' : ' ';
|
|
134
|
+
if (lineNumbers) {
|
|
135
|
+
const number = op.type === 'del' ? ''.padStart(width) : String(newLineNo).padStart(width);
|
|
136
|
+
out.push(`${prefix} ${number} | ${op.line}`);
|
|
137
|
+
} else {
|
|
138
|
+
out.push(`${prefix} ${op.line}`);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
return { body: out.join('\n'), added, removed };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The compact +/- summary with 1 line of context, under a one-line header -
|
|
145
|
+
// a full unified diff's hunk headers aren't worth the tokens here, the model
|
|
146
|
+
// just needs to see what changed between two attempts.
|
|
147
|
+
export function formatDiffSummary(oldCode, newCode) {
|
|
148
|
+
if (oldCode === newCode) return '(code unchanged from the previous attempt)';
|
|
149
|
+
const { body, added, removed } = formatDiffBody(oldCode, newCode);
|
|
150
|
+
return `Code changed from the previous attempt (+${added}/-${removed} lines):\n${body}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Builds the actual outgoing messages array for a stateless request: the
|
|
154
|
+
// leading system message, the pinned problem statement right after it (if
|
|
155
|
+
// one is known), then the pinned code (the student's current full code, with
|
|
156
|
+
// this turn's change already applied), then the summary of condensed older
|
|
157
|
+
// exchanges if there is one (context-summary.js), then the rest of history.
|
|
158
|
+
// Most stable first, so a server's prefix cache keeps as much as it can. An
|
|
159
|
+
// older user turn with a `compactContent` (a Submit's answer instructions)
|
|
160
|
+
// is sent as that while code is pinned; the turn just pushed for this
|
|
161
|
+
// request is always last and always sent in full.
|
|
162
|
+
//
|
|
163
|
+
// Pinning is what lets condensing or dropping old exchanges
|
|
164
|
+
// (context-summary.js) never lose the statement or the code: every request
|
|
165
|
+
// carries both whole. Pure function of its inputs -
|
|
166
|
+
// LocalBackend.buildMessages (companion.js) is a thin wrapper passing its
|
|
167
|
+
// own fields through - so it can be unit-tested without a real backend.
|
|
168
|
+
// `history` is never mutated.
|
|
169
|
+
// The "earlier in this session" summary (context-summary.js) that stands in
|
|
170
|
+
// for condensed older exchanges: its header, and the message it is sent as.
|
|
171
|
+
export const SUMMARY_HEADER =
|
|
172
|
+
'Earlier in this session (a short summary of older exchanges that are no longer shown in full):';
|
|
173
|
+
|
|
174
|
+
export function summaryContent(summary) {
|
|
175
|
+
return `${SUMMARY_HEADER}\n${summary}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function buildMessages({ history, pinnedProblemContext, pinnedCode = null, summary = null }) {
|
|
179
|
+
const messages = [history[0]]; // the leading system message
|
|
180
|
+
if (pinnedProblemContext?.description) {
|
|
181
|
+
messages.push({
|
|
182
|
+
role: 'system',
|
|
183
|
+
content:
|
|
184
|
+
`Problem currently being discussed: ${pinnedProblemContext.title} ` +
|
|
185
|
+
`(${pinnedProblemContext.slug})\n\n${pinnedProblemContext.description}`,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
if (pinnedCode && typeof pinnedCode.text === 'string') {
|
|
189
|
+
messages.push({ role: 'system', content: pinnedCode.text });
|
|
190
|
+
}
|
|
191
|
+
if (typeof summary === 'string' && summary) {
|
|
192
|
+
messages.push({ role: 'system', content: summaryContent(summary) });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const rest = history.slice(1);
|
|
196
|
+
const currentTurnIndex = rest.length - 1;
|
|
197
|
+
for (let i = 0; i < rest.length; i++) {
|
|
198
|
+
const msg = rest[i];
|
|
199
|
+
const compact =
|
|
200
|
+
Boolean(pinnedCode) && msg.role === 'user' && i !== currentTurnIndex && typeof msg.compactContent === 'string';
|
|
201
|
+
messages.push({ role: msg.role, content: compact ? msg.compactContent : msg.content });
|
|
202
|
+
}
|
|
203
|
+
return messages;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// The context window LocalBackend budgets against: the smallest of what is
|
|
207
|
+
// configured (COMPANION_LOCAL_NUM_CTX, or a settings field later), what the
|
|
208
|
+
// server reported when probed (local-server.js), and what a reply showed
|
|
209
|
+
// the server really kept (LocalBackend's silent-truncation guard). Any of
|
|
210
|
+
// the last two may be missing. Returns the window and where it came from,
|
|
211
|
+
// so messages can say which one is in charge. The one place the window is
|
|
212
|
+
// decided, so a new source (a settings field) only needs a new argument.
|
|
213
|
+
export function resolveContextWindow({ configured, detected = null, observed = null }) {
|
|
214
|
+
let best = { tokens: configured, source: 'configured' };
|
|
215
|
+
if (detected && detected.tokens > 0 && detected.tokens < best.tokens) {
|
|
216
|
+
best = { tokens: detected.tokens, source: 'detected', from: detected.source };
|
|
217
|
+
}
|
|
218
|
+
if (observed && observed > 0 && observed < best.tokens) best = { tokens: observed, source: 'observed' };
|
|
219
|
+
return best;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Fits one request to the prompt budget by dropping, the last resort behind
|
|
223
|
+
// context-summary.js's fitWithSummary (which condenses instead whenever even
|
|
224
|
+
// a small summary fits, and is what LocalBackend calls): `history` is
|
|
225
|
+
// [system, user, assistant, ..., user] with the current message last. While
|
|
226
|
+
// the request is over budget, the oldest whole user+assistant exchange is
|
|
227
|
+
// dropped (never half of one, never the pinned statement or code, never the
|
|
228
|
+
// current message). Returns the history to keep, the messages to send and
|
|
229
|
+
// their estimate, how many exchanges went, and whether it fits. When even
|
|
230
|
+
// the pinned parts plus the current message are over, `parts` gives each
|
|
231
|
+
// one's estimate so the refusal can say what is too big. Pure: `history`
|
|
232
|
+
// is never mutated.
|
|
233
|
+
export function fitToBudget({ history, pinnedProblemContext = null, pinnedCode = null, budget }) {
|
|
234
|
+
let kept = history;
|
|
235
|
+
let droppedPairs = 0;
|
|
236
|
+
let messages = buildMessages({ history: kept, pinnedProblemContext, pinnedCode });
|
|
237
|
+
let estimatedTokens = estimateMessagesTokens(messages);
|
|
238
|
+
// kept is [system, (user, assistant)*, current]: an exchange is left to
|
|
239
|
+
// drop while there are at least four entries.
|
|
240
|
+
while (estimatedTokens > budget && kept.length >= 4 && kept[1].role === 'user' && kept[2].role === 'assistant') {
|
|
241
|
+
kept = [kept[0], ...kept.slice(3)];
|
|
242
|
+
droppedPairs += 1;
|
|
243
|
+
messages = buildMessages({ history: kept, pinnedProblemContext, pinnedCode });
|
|
244
|
+
estimatedTokens = estimateMessagesTokens(messages);
|
|
245
|
+
}
|
|
246
|
+
const fits = estimatedTokens <= budget;
|
|
247
|
+
const result = { history: kept, messages, estimatedTokens, droppedPairs, fits };
|
|
248
|
+
if (!fits) {
|
|
249
|
+
const current = kept[kept.length - 1];
|
|
250
|
+
const problemTokens = pinnedProblemContext?.description ? estimateTokens(messages[1].content) : 0;
|
|
251
|
+
result.parts = {
|
|
252
|
+
systemPrompt: estimateTokens(kept[0].content),
|
|
253
|
+
problem: problemTokens,
|
|
254
|
+
code: pinnedCode && typeof pinnedCode.text === 'string' ? estimateTokens(pinnedCode.text) : 0,
|
|
255
|
+
message: estimateTokens(current.content),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
return result;
|
|
259
|
+
}
|