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,330 @@
1
+ // companion/terminal-format.js - color/prompt styling and markdown rendering
2
+ // for companion.js's terminal output.
3
+ //
4
+ // Deliberately isolated from companion.js so the styling decision (TTY or
5
+ // not, NO_COLOR or not) is made in exactly one place, computed once at
6
+ // import time, and every exported helper already honors it internally - no
7
+ // caller needs its own isTTY branch. This also keeps companion.js's own
8
+ // stdout-vs-pipe branch (printAboveInput) untouched: it still just receives
9
+ // an opaque string to print, whether or not that string carries ANSI codes.
10
+ //
11
+ // Why the styling decision is made ourselves rather than left to chalk's or
12
+ // marked-terminal's own auto-detection: this environment sometimes runs
13
+ // with FORCE_COLOR set even when stdout is a plain pipe (confirmed via
14
+ // `node -e '...' | cat` still reporting chalk level 3), which would defeat
15
+ // the "stay plain in pipes" requirement if we trusted ambient detection.
16
+ // process.stdout.isTTY is checked directly instead, plus the NO_COLOR
17
+ // (https://no-color.org) convention.
18
+
19
+ import { Chalk } from 'chalk';
20
+ import { marked } from 'marked';
21
+ import TerminalRenderer, { markedTerminal } from 'marked-terminal';
22
+
23
+ // marked-terminal's own listitem() only reflow-wraps a *loose* list item
24
+ // (one with a blank line between bullets - marked hands it 'paragraph'-type
25
+ // tokens): it forwards to marked's own Parser.parse(item.tokens,
26
+ // !!item.loose), and marked's Parser only routes a 'text'-type token
27
+ // through paragraph rendering - and therefore through reflowText - when
28
+ // that `top` flag is true (see marked's Parser.prototype.parse's "text"
29
+ // case). A *tight* item (the common case - no blank line between bullets,
30
+ // which is what an LLM's own list output almost always looks like) carries
31
+ // plain 'text' tokens instead, so with top=false its text comes out
32
+ // completely unwrapped; whatever line break then appears on screen is only
33
+ // the raw terminal's own hardware auto-wrap-at-column-N, which can land
34
+ // mid-word with zero hanging indent - confirmed live once PR #36 removed
35
+ // the old fixed-width cap and made a long single-sentence bullet an
36
+ // everyday occurrence rather than a rare edge case (see companion/AGENTS.md
37
+ // and the PR that added this fix for the live repro).
38
+ //
39
+ // Patched once here, directly on marked-terminal's shared
40
+ // TerminalRenderer.prototype (rather than inside configureMarkedTerminal
41
+ // below, which re-registers on every terminal resize - wrapping listitem()
42
+ // again on every one of those would stack redundant wrappers), by forcing
43
+ // every item to look "loose" to marked's Parser. Confirmed this doesn't
44
+ // change what's rendered for an already-loose item (identical either way)
45
+ // or for a tight *task*/checkbox item (whose tokens[0] is 'text' rather
46
+ // than the 'paragraph' the loose branch expects, so it falls through to the
47
+ // same unshift-a-checkbox-token behavior either way) - every other item
48
+ // just takes the same parser.parse(item.tokens, true) call a genuinely
49
+ // loose item's text already took, which is what actually turns reflowText
50
+ // on for it.
51
+ const originalListitem = TerminalRenderer.prototype.listitem;
52
+ TerminalRenderer.prototype.listitem = function (item) {
53
+ if (item && typeof item === 'object' && !item.loose) {
54
+ item = { ...item, loose: true };
55
+ }
56
+ return originalListitem.call(this, item);
57
+ };
58
+
59
+ export function stylingEnabled(stream = process.stdout) {
60
+ return Boolean(stream && stream.isTTY) && !process.env.NO_COLOR;
61
+ }
62
+
63
+ const enabled = stylingEnabled();
64
+
65
+ // Forced to level 1 (standard 16-color ANSI) rather than left to chalk's own
66
+ // ambient detection - readable on both light and dark backgrounds without
67
+ // depending on truecolor support, and avoids the FORCE_COLOR quirk noted
68
+ // above ever leaking a color decision we didn't intend.
69
+ const c = new Chalk({ level: enabled ? 1 : 0 });
70
+
71
+ // Visual width of turnMarker()'s own text ('•' plus its one trailing space -
72
+ // see turnMarker below), ignoring ANSI codes: the bullet is a single-column
73
+ // glyph in a monospace terminal. This is the hanging-indent column every
74
+ // wrapped/continuation line of a turnMarker-led reply aligns under (see
75
+ // indentContinuation below), and also how much narrower marked-terminal's
76
+ // own reflow width needs to be so an indented line still fits the terminal
77
+ // instead of running TURN_MARKER_WIDTH columns past it.
78
+ const TURN_MARKER_WIDTH = 2;
79
+
80
+ // marked-terminal's own list rendering adds a further indent on top of
81
+ // whatever reflowText already wrapped a list item's text to: a tab-stop
82
+ // (marked-terminal's own default `tab` option, left unset here so it's 4)
83
+ // plus the bullet marker's own width (its internal BULLET_POINT constant,
84
+ // '* ' - 2 columns), applied to every line of a list via
85
+ // indentLines/bulletPointLines *after* reflow already ran - see
86
+ // marked-terminal's Renderer.prototype.list. Unlike TURN_MARKER_WIDTH
87
+ // above, neither of those is subtracted from the width reflowText wraps
88
+ // to, so a list item's own continuation line could still run past
89
+ // contentWidth() once that indent lands on top of an already
90
+ // maximally-reflowed line - confirmed live in a real 260-column tmux pane:
91
+ // a hyphenated word ("off-by-one", which reflowText correctly never
92
+ // splits internally - it isn't whitespace) landed exactly on that
93
+ // unaccounted overhang and got hard-split by the terminal's own hardware
94
+ // wrap instead, the exact same mid-word-break symptom this file's list-
95
+ // item reflow fix (below) exists to prevent. Covers exactly one level of
96
+ // list nesting - the common case an LLM reply actually produces; a list
97
+ // nested inside a list item could still overflow by a further
98
+ // LIST_INDENT_OVERHEAD per extra nesting level, not accounted for here as
99
+ // disproportionate to the reported bug. Subtracted from the *global*
100
+ // reflow width (which also governs paragraphs, which don't need it)
101
+ // rather than only for list rendering specifically - marked-terminal
102
+ // doesn't expose a per-element-type width, and paragraphs losing a few
103
+ // columns of a terminal that's often 200+ wide is a non-issue next to a
104
+ // list item actually overflowing and hard-breaking mid-word.
105
+ const LIST_INDENT_OVERHEAD = 6; // marked-terminal's default tab (4) + BULLET_POINT width (2)
106
+
107
+ // The box rule and prose wrap track the real terminal width exactly, with
108
+ // no upper bound - this used to cap at a fixed ~80 columns (later raised to
109
+ // a 120-column ceiling) regardless of how wide the terminal actually was,
110
+ // which read as "the width isn't tracking the window" on anything wider
111
+ // than that (confirmed live: a 100+ col terminal still rendered an 80-col
112
+ // rule with visible unused space to its right). Thomson confirmed he wants
113
+ // this literally uncapped - a fullscreen monitor is often 200-300+ columns,
114
+ // and even 120 still read as capped - so there is deliberately no ceiling
115
+ // here any more. `|| 80` only covers the case where the terminal hasn't
116
+ // reported a column count at all (columns is undefined/0), not a readability
117
+ // cap.
118
+ function contentWidth() {
119
+ return process.stdout.columns || 80;
120
+ }
121
+
122
+ // Registers marked-terminal's renderer with the current width. Callable
123
+ // more than once - re-registering with a fresh width is exactly how a
124
+ // terminal resize is picked up (see refreshWidth below), since marked's own
125
+ // `use()` replaces the previously-registered renderer rather than stacking
126
+ // on top of it.
127
+ function configureMarkedTerminal() {
128
+ marked.use(
129
+ markedTerminal({
130
+ // Reserves TURN_MARKER_WIDTH columns for the hanging indent every
131
+ // rendered reply gets on top of this (renderMarkdown/
132
+ // createMarkdownStreamer are only ever used for backend replies,
133
+ // which always carry turnMarker()'s indent - see companion.js's
134
+ // sendAndPrintTurn) - without this, a paragraph reflowed right up to
135
+ // contentWidth() would then get indented past it once
136
+ // indentContinuation runs, overflowing the terminal's actual column
137
+ // count instead of staying within the terminal's own width. Also
138
+ // reserves LIST_INDENT_OVERHEAD (see above) for the further indent
139
+ // list rendering adds on top of that.
140
+ width: contentWidth() - TURN_MARKER_WIDTH - LIST_INDENT_OVERHEAD,
141
+ // Reflow (word-wrap) prose text to the width above rather than
142
+ // leaving lines exactly as the model generated them - the width
143
+ // above already matches the terminal's own edge (minus the hanging
144
+ // indent), so without this, text generated with its own line breaks
145
+ // (e.g. a numbered list item) could still run past it.
146
+ reflowText: true,
147
+ })
148
+ );
149
+ }
150
+
151
+ if (enabled) configureMarkedTerminal();
152
+
153
+ // Re-registers marked-terminal's renderer against the terminal's *current*
154
+ // width - call this after a resize (companion.js listens for
155
+ // process.stdout's 'resize' event) so markdown rendered from here on wraps
156
+ // to the new size instead of whatever was captured at import time. A no-op
157
+ // when styling is off, matching every other helper in this module - nothing
158
+ // uses marked-terminal's renderer over a pipe anyway.
159
+ export function refreshWidth() {
160
+ if (enabled) configureMarkedTerminal();
161
+ }
162
+
163
+ // De-emphasizes companion's own status/system lines (startup banner, relay
164
+ // server lifecycle, warnings, errors) relative to actual conversation
165
+ // content. A no-op string pass-through when styling is off.
166
+ export function dim(text) {
167
+ return enabled ? c.dim(text) : text;
168
+ }
169
+
170
+ // The readline prompt marker. Plain '> ' when styling is off, matching the
171
+ // prompt this program has always used; a one-space left margin plus the
172
+ // colored ">" when styling is on, so the prompt lines up under boxRule's own
173
+ // matching one-space margin below rather than starting flush at column 0 -
174
+ // no "you"/role text (see turnMarker below for the tutor-reply side of the
175
+ // same convention).
176
+ export function promptString() {
177
+ return enabled ? ` ${c.cyanBright.bold('>')} ` : '> ';
178
+ }
179
+
180
+ // The marker opening a tutor reply turn - a single bullet, replacing the
181
+ // old "tutor" role label and framing rule. '' when styling is off, same
182
+ // convention as the rest of this module's labels: callers must skip adding
183
+ // it entirely in that case, not print an empty prefix, so non-TTY output
184
+ // stays exactly the raw reply text.
185
+ export function turnMarker() {
186
+ if (!enabled) return '';
187
+ return `${c.magentaBright('•')} `;
188
+ }
189
+
190
+ // Indents every line of `text` except its very first by TURN_MARKER_WIDTH
191
+ // spaces, so a multi-line turnMarker-led reply's wrapped/continuation lines
192
+ // align under where the text starts after the bullet - a hanging indent -
193
+ // rather than wrapping back to column 0. Matches a Claude Code CLI
194
+ // transcript's own look (see companion/AGENTS.md's "message padding" note).
195
+ // Blank lines are left untouched rather than padded with trailing
196
+ // whitespace, so a blank separator row between blocks still reads as
197
+ // genuinely empty. `indentFirstLine: true` indents the first line too - for
198
+ // a streamed reply's second-or-later piece (see companion.js's
199
+ // writeReplyPiece), which starts on its own fresh line that still needs the
200
+ // same indent as every other continuation line, unlike the very first piece
201
+ // where turnMarker() itself already occupies that column. A no-op when
202
+ // styling is off, same convention as the rest of this module: there is no
203
+ // marker or indent to align under over a pipe.
204
+ export function indentContinuation(text, { indentFirstLine = false } = {}) {
205
+ if (!enabled) return text;
206
+ const pad = ' '.repeat(TURN_MARKER_WIDTH);
207
+ return text
208
+ .split('\n')
209
+ .map((line, i) => ((i === 0 && !indentFirstLine) || line === '' ? line : pad + line))
210
+ .join('\n');
211
+ }
212
+
213
+ // A thin rule marking off the pinned input box from the conversation above
214
+ // it, at the same width prose wraps to (minus the one-space left margin
215
+ // below, so the rule's right edge still lands at the terminal's own edge
216
+ // rather than running one column past it). A one-space left margin -
217
+ // matching promptString's own - gives the box a consistent inset rather than
218
+ // starting flush at column 0; companion.js's drawBoxRaw also prints a blank
219
+ // line above this rule for breathing room from whatever chat content
220
+ // precedes it. '' when styling is off - there is no persistent input box
221
+ // over a pipe.
222
+ export function boxRule() {
223
+ if (!enabled) return '';
224
+ return ` ${c.dim('─'.repeat(contentWidth() - 1))}`;
225
+ }
226
+
227
+ // Renders markdown (headers, bold/emphasis, lists, fenced code blocks with
228
+ // syntax highlighting) to ANSI-styled text for a real TTY, wrapped to the
229
+ // width above. Returns the text unchanged when styling is off, so piped
230
+ // output keeps showing the raw markdown exactly as before this feature
231
+ // existed.
232
+ export function renderMarkdown(text) {
233
+ if (!enabled) return text;
234
+ // marked-terminal pads block elements with blank lines; collapse runs of
235
+ // 3+ into a single blank line and trim trailing whitespace so replies
236
+ // don't accumulate extra vertical space compared to before.
237
+ return marked.parse(text).replace(/\n{3,}/g, '\n\n').trimEnd();
238
+ }
239
+
240
+ // --- streaming reply support -------------------------------------------
241
+ //
242
+ // Two pieces on top of the styling/rendering above: rendering markdown
243
+ // safely as it streams in rather than only once a reply is fully done, and
244
+ // a spinner while waiting on the backend. companion.js owns *when* a reply
245
+ // starts/ends and how each piece reaches the terminal (it has the readline
246
+ // instance and the pinned input box); this module only owns what each piece
247
+ // looks like.
248
+
249
+ // One frame of the "waiting for the backend" spinner, cycling through
250
+ // SPINNER_FRAME_COUNT frames on request - callers own the timing
251
+ // (setInterval) and where each frame is drawn, since that needs readline
252
+ // coordination this module doesn't have. Plain, unstyled text when styling
253
+ // is off (companion.js's spinner is itself a no-op in that case, but the
254
+ // frame text stays meaningful if ever printed directly, e.g. in a test).
255
+ const SPINNER_GLYPHS = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
256
+ export const SPINNER_FRAME_COUNT = SPINNER_GLYPHS.length;
257
+ export function spinnerFrame(i) {
258
+ const glyph = SPINNER_GLYPHS[((i % SPINNER_GLYPHS.length) + SPINNER_GLYPHS.length) % SPINNER_GLYPHS.length];
259
+ const text = `${glyph} thinking`;
260
+ // A one-space left margin, matching boxRule/promptString's own - the
261
+ // spinner temporarily occupies the same column position the box's prompt
262
+ // normally does, so it should line up with it rather than sitting one
263
+ // column further left.
264
+ return enabled ? ` ${c.dim(text)}` : text;
265
+ }
266
+
267
+ // Feeds a reply in incrementally, rendering complete markdown as soon as a
268
+ // safe boundary is reached: a blank line outside a fenced code block, or the
269
+ // close of a fenced code block itself. Never mid-fence, never mid-block -
270
+ // marked-terminal renders those as broken/uncoloured text. push() returns
271
+ // text ready to print now ('' if nothing new is complete yet); finish()
272
+ // flushes whatever's left once the stream ends, complete or not (the common
273
+ // case: a reply's last block has no trailing blank line).
274
+ //
275
+ // When styling is off, this is a pure pass-through - each push() returns
276
+ // its chunk unchanged, finish() has nothing left to flush - since raw
277
+ // markdown is never rendered either way (see renderMarkdown above), the
278
+ // same as a non-streaming reply already looks over a pipe.
279
+ export function createMarkdownStreamer() {
280
+ if (!enabled) {
281
+ return { push: (chunk) => chunk, finish: () => '' };
282
+ }
283
+
284
+ let buffer = '';
285
+
286
+ return {
287
+ push(chunk) {
288
+ buffer += chunk;
289
+ const splitAt = findSafeSplit(buffer);
290
+ if (splitAt <= 0) return '';
291
+ const complete = buffer.slice(0, splitAt);
292
+ buffer = buffer.slice(splitAt);
293
+ return renderMarkdown(complete);
294
+ },
295
+ finish() {
296
+ if (!buffer) return '';
297
+ const rendered = renderMarkdown(buffer);
298
+ buffer = '';
299
+ return rendered;
300
+ },
301
+ };
302
+ }
303
+
304
+ // Single left-to-right scan tracking fenced-code-block open/close state,
305
+ // returning the end index of the latest position it's safe to cut at:
306
+ // right after a closing fence's own line, or a blank line encountered while
307
+ // not inside an open fence. -1 if no such point exists yet. A plain scan
308
+ // rather than a regex split specifically so an odd/open fence count is
309
+ // tracked incrementally and blank lines inside it are correctly ignored.
310
+ function findSafeSplit(text) {
311
+ let fenceCount = 0;
312
+ let lastSafe = -1;
313
+ let i = 0;
314
+ while (i < text.length) {
315
+ if (text.startsWith('```', i)) {
316
+ fenceCount += 1;
317
+ i += 3;
318
+ if (fenceCount % 2 === 0) {
319
+ const lineEnd = text.indexOf('\n', i);
320
+ if (lineEnd !== -1) lastSafe = lineEnd + 1;
321
+ }
322
+ continue;
323
+ }
324
+ if (fenceCount % 2 === 0 && text.startsWith('\n\n', i)) {
325
+ lastSafe = i + 2;
326
+ }
327
+ i += 1;
328
+ }
329
+ return lastSafe;
330
+ }
@@ -0,0 +1,168 @@
1
+ // companion/vault-summary.js - vault auto-summary logic
2
+ //
3
+ // Split out from companion.js so this feature's actual logic (building the
4
+ // addendum, parsing the model's reply, writing the notes) can be exercised
5
+ // directly by tests without booting companion.js's interactive readline
6
+ // loop or relay-server lifecycle. See companion.js for how this gets wired
7
+ // into the capture handling path.
8
+ //
9
+ // The core constraint shaping this file: reuse the same backend response
10
+ // already being shown in the terminal for a Submit, and never make a
11
+ // second LLM call just for the vault note. So buildVaultAddendum appends
12
+ // one extra instruction to the Submit message, asking the model to tack a
13
+ // machine-readable block onto the very end of the same reply it was
14
+ // already going to give; extractVaultBlock then peels that block back off
15
+ // before the reply is printed, so the chat experience is unchanged.
16
+ //
17
+ // The model infers both the submitted code's complexity and the best
18
+ // achievable complexity, so the resulting star rating is a judgment call.
19
+ // Topic names are local vault metadata and never enter the addendum.
20
+
21
+ import fs from 'node:fs';
22
+ import { createRequire } from 'node:module';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const vaultNotes = require('../vault-tool/vault-notes.js');
26
+
27
+ export const defaultVaultPath = vaultNotes.defaultVaultPath;
28
+ export const resolveVaultConfig = vaultNotes.resolveVaultConfig;
29
+ export const validateVaultPath = vaultNotes.validateVaultPath;
30
+
31
+ const VAULT_JSON_RE = /<<<VAULT_JSON>>>([\s\S]*?)<<<END_VAULT_JSON>>>/;
32
+ export const AI_NOTES_HEADER = '## AI Notes';
33
+ export const AI_TOPIC_INDEX_HEADER = '## AI Topic Index';
34
+
35
+ export function clampInt(value, min, max) {
36
+ const n = Math.round(Number(value));
37
+ if (!Number.isFinite(n)) return null;
38
+ return Math.min(max, Math.max(min, n));
39
+ }
40
+
41
+ export function starRatingLine(stars) {
42
+ const full = Math.max(0, Math.min(5, stars ?? 0));
43
+ return `${'⭐'.repeat(full)}${'☆'.repeat(5 - full)} (${stars ?? '?'}/5)`;
44
+ }
45
+
46
+ // Keeps captured tags in local vault metadata and builds a tag-free addendum
47
+ // instructing the model to emit the structured block. Returns null if the capture
48
+ // doesn't carry enough to act on (missing slug/title - shouldn't happen
49
+ // with a current extension build, but captures can predate a field).
50
+ export function prepareVaultContext({ capture, vaultPath, algorithmsSubfolder }) {
51
+ if (!capture.problemSlug || !capture.problemTitle) return null;
52
+
53
+ const tags = Array.isArray(capture.problemTags)
54
+ ? capture.problemTags.filter((t) => typeof t === 'string' && t.trim())
55
+ : [];
56
+
57
+ const problemNotePath = vaultNotes.ensurePerProblemNoteFile(
58
+ vaultPath,
59
+ { problemSlug: capture.problemSlug, problemTitle: capture.problemTitle },
60
+ algorithmsSubfolder
61
+ );
62
+
63
+ // Only tags that already have a curated Study/Algorithms/<Tag>.md note get
64
+ // linked - this deliberately doesn't invent a new topic note for
65
+ // every raw LeetCode tag (see vault-notes.js's findTopicNoteByTagName).
66
+ const matchedTopics = [];
67
+ const topicNotePaths = {};
68
+ for (const tag of tags) {
69
+ const topicPath = vaultNotes.findTopicNoteByTagName(vaultPath, tag, algorithmsSubfolder);
70
+ if (!topicPath) continue;
71
+ matchedTopics.push(tag);
72
+ topicNotePaths[tag] = topicPath;
73
+ }
74
+
75
+ const addendum = buildVaultAddendum();
76
+ return { problemNotePath, tags, matchedTopics, topicNotePaths, addendum };
77
+ }
78
+
79
+ export function buildVaultAddendum() {
80
+ const lines = [
81
+ '',
82
+ '---',
83
+ "This was a Submit capture with the vault auto-summary feature on. After your tutoring response above, append exactly one more block at the very end, between the literal markers <<<VAULT_JSON>>> and <<<END_VAULT_JSON>>>, containing a single-line JSON object (no markdown, no code fence around it) with these exact keys:",
84
+ '- "rightThings": string, 1-3 sentences on what the student\'s submitted code does right, based on your analysis above.',
85
+ '- "notConnecting": string, 1-3 sentences on what the student does not seem to be connecting or missing. If the solution is already fully correct and optimal, say so plainly here instead of inventing a gap.',
86
+ '- "problemStars": integer 1-5, rating purely how this code\'s inferred time complexity compares to the best achievable time complexity for this exact problem. Matching the optimal is 5, regardless of what the optimal actually is. This is your best inference, not a verified fact.',
87
+ '- "complexityNote": string, one sentence stating the inferred complexity of the submitted code and the inferred optimal complexity for this problem.',
88
+ '- "topicProficiency": an empty object. Topic tags stay in the local vault writer.',
89
+ ];
90
+ return lines.join('\n');
91
+ }
92
+
93
+ // Splits a backend reply into the text meant for the terminal and the
94
+ // parsed vault JSON block, if the addendum's marker is present. Always
95
+ // strips the raw block from displayText, even if JSON.parse fails, so a
96
+ // malformed block never leaks into the chat transcript. `warn`, if given, is
97
+ // called with a message when the block is present but unparseable.
98
+ export function extractVaultBlock(text, { warn } = {}) {
99
+ const match = text.match(VAULT_JSON_RE);
100
+ if (!match) return { displayText: text, vaultData: null };
101
+
102
+ const displayText = (text.slice(0, match.index) + text.slice(match.index + match[0].length))
103
+ .replace(/\n{3,}/g, '\n\n')
104
+ .trim();
105
+
106
+ let vaultData = null;
107
+ try {
108
+ vaultData = JSON.parse(match[1].trim());
109
+ } catch (err) {
110
+ if (warn) warn(`could not parse vault auto-summary JSON (${err.message})`);
111
+ }
112
+ return { displayText, vaultData };
113
+ }
114
+
115
+ // Rewrites a per-problem note's "AI Notes" section and every matched topic
116
+ // note's "AI Topic Index" section in place. The topic index is rebuilt as
117
+ // an overall proficiency line plus one wikilinked bullet per problem
118
+ // attempted under that topic, keyed by title so updating one problem's line
119
+ // never disturbs the others.
120
+ export function writeVaultAutoSummary({ capture, vaultContext, vaultData }) {
121
+ const stars = clampInt(vaultData.problemStars, 1, 5);
122
+ const topicsLine = vaultContext.tags.length
123
+ ? vaultContext.tags.map((tag) => (vaultContext.matchedTopics.includes(tag) ? `[[${tag}]]` : tag)).join(', ')
124
+ : '(none captured)';
125
+ const updatedAt = new Date().toISOString();
126
+
127
+ const aiNotesBody = [
128
+ `**Rating:** ${starRatingLine(stars)}`,
129
+ `**Complexity:** ${vaultData.complexityNote || '(not provided)'}`,
130
+ `**Topics:** ${topicsLine}`,
131
+ '',
132
+ `**Doing right:** ${vaultData.rightThings || '(not provided)'}`,
133
+ '',
134
+ `**Not connecting:** ${vaultData.notConnecting || '(not provided)'}`,
135
+ '',
136
+ `*Last updated ${updatedAt} after submit attempt #${capture.attemptSeq ?? '?'}. Rating is an AI inference, not a verified fact - both the optimal complexity for this problem and this code's complexity are estimated by the model, not looked up. Reliable for well-known problems, may be wrong on obscure ones.*`,
137
+ ].join('\n');
138
+
139
+ const problemContent = fs.readFileSync(vaultContext.problemNotePath, 'utf8');
140
+ fs.writeFileSync(vaultContext.problemNotePath, vaultNotes.upsertSection(problemContent, AI_NOTES_HEADER, aiNotesBody));
141
+
142
+ for (const topic of vaultContext.matchedTopics) {
143
+ const topicPath = vaultContext.topicNotePaths[topic];
144
+ const proficiency = clampInt(vaultData.topicProficiency && vaultData.topicProficiency[topic], 1, 10);
145
+ const topicContent = fs.readFileSync(topicPath, 'utf8');
146
+ const existingBody = vaultNotes.readSection(topicContent, AI_TOPIC_INDEX_HEADER);
147
+
148
+ const bulletsByTitle = new Map();
149
+ if (existingBody) {
150
+ for (const line of existingBody.split('\n')) {
151
+ const m = line.match(/^- \[\[(.+?)\]\] - (.+)$/);
152
+ if (m) bulletsByTitle.set(m[1], m[2]);
153
+ }
154
+ }
155
+ bulletsByTitle.set(capture.problemTitle, starRatingLine(stars));
156
+
157
+ const sortedTitles = Array.from(bulletsByTitle.keys()).sort((a, b) => a.localeCompare(b));
158
+ const overallLine =
159
+ proficiency !== null
160
+ ? `**Overall proficiency:** ${proficiency}/10 (AI holistic judgment)`
161
+ : existingBody?.match(/^\*\*Overall proficiency:.*$/m)?.[0] || '**Overall proficiency:** (not yet rated)';
162
+ const newBody = [overallLine, '', ...sortedTitles.map((title) => `- [[${title}]] - ${bulletsByTitle.get(title)}`)].join(
163
+ '\n'
164
+ );
165
+
166
+ fs.writeFileSync(topicPath, vaultNotes.upsertSection(topicContent, AI_TOPIC_INDEX_HEADER, newBody));
167
+ }
168
+ }
@@ -0,0 +1,138 @@
1
+ // companion/virtual-terminal.js - a small ANSI escape-code interpreter that
2
+ // replays a raw byte stream companion.js wrote to stdout into the 2D grid a
3
+ // real terminal would actually be showing, given the current cursor
4
+ // position and the escape codes companion.js/readline emitted along the
5
+ // way.
6
+ //
7
+ // Exists specifically because some of companion.js's box-pinning bugs (see
8
+ // AGENTS.md - the readline `prevRows` desync behind "a capture arriving
9
+ // while a long typed line occupies the prompt corrupts everything above the
10
+ // box") are about *interpreted* rendering: a `moveCursor` + `clearScreenDown`
11
+ // sequence overwrites text that's still physically present, byte for byte,
12
+ // earlier in the raw captured stream. A plain text/line search over raw
13
+ // output (the technique box-padding.test.js already uses for the simpler
14
+ // padding-math case, where padding really is just literal '\n' characters)
15
+ // can't tell "this text is still on screen" apart from "this text was
16
+ // already erased by a later escape sequence" - both leave the same bytes
17
+ // sitting in the capture. Only replaying the escapes against a real cursor
18
+ // position/grid, the way a terminal does, can.
19
+ //
20
+ // Scoped deliberately narrow: only the escape sequences companion.js itself
21
+ // emits directly (see its readline.clearLine/cursorTo/moveCursor calls and
22
+ // its own literal '\x1b[2J\x1b[H'/'\x1b[?1049h' writes) and the ones node's
23
+ // own readline._refreshLine emits internally on rl.prompt(true) (moveCursor,
24
+ // cursorTo, clearScreenDown - confirmed empirically, see this file's own
25
+ // test for the exact bytes) - not a general-purpose terminal emulator.
26
+ // Private-mode sequences (`\x1b[?...h`/`l`, e.g. the alternate-screen and
27
+ // mouse-tracking toggles) and SGR color codes (`\x1b[...m`) are recognized
28
+ // and ignored (no visible effect on the grid) rather than mishandled.
29
+
30
+ const CSI_RE = /\x1b\[([0-9;?]*)([A-Za-z])/g;
31
+
32
+ export function replayToScreen(raw, { cols, rows }) {
33
+ const screen = Array.from({ length: rows }, () => new Array(cols).fill(' '));
34
+ let row = 0;
35
+ let col = 0;
36
+
37
+ function clearRow(r, fromCol, toCol) {
38
+ for (let c = fromCol; c <= toCol; c += 1) screen[r][c] = ' ';
39
+ }
40
+
41
+ function scrollUp() {
42
+ screen.shift();
43
+ screen.push(new Array(cols).fill(' '));
44
+ }
45
+
46
+ function newline() {
47
+ row += 1;
48
+ col = 0;
49
+ if (row >= rows) {
50
+ scrollUp();
51
+ row = rows - 1;
52
+ }
53
+ }
54
+
55
+ function putChar(ch) {
56
+ if (col >= cols) newline();
57
+ screen[row][col] = ch;
58
+ col += 1;
59
+ }
60
+
61
+ let i = 0;
62
+ while (i < raw.length) {
63
+ const ch = raw[i];
64
+ if (ch === '\x1b' && raw[i + 1] === '[') {
65
+ CSI_RE.lastIndex = i;
66
+ const match = CSI_RE.exec(raw);
67
+ if (!match || match.index !== i) {
68
+ // Not a well-formed CSI sequence after all - skip just the ESC so
69
+ // we don't get stuck in an infinite loop on stray bytes.
70
+ i += 1;
71
+ continue;
72
+ }
73
+ const params = match[1];
74
+ const final = match[2];
75
+ const isPrivate = params.startsWith('?');
76
+ const nums = isPrivate ? [] : params.split(';').filter(Boolean).map(Number);
77
+ if (!isPrivate) {
78
+ switch (final) {
79
+ case 'A': // cursor up
80
+ row = Math.max(0, row - (nums[0] || 1));
81
+ break;
82
+ case 'B': // cursor down
83
+ row = Math.min(rows - 1, row + (nums[0] || 1));
84
+ break;
85
+ case 'G': // cursor to column (1-indexed)
86
+ col = Math.max(0, (nums[0] || 1) - 1);
87
+ break;
88
+ case 'H': { // cursor position (row;col, 1-indexed)
89
+ row = Math.max(0, Math.min(rows - 1, (nums[0] || 1) - 1));
90
+ col = Math.max(0, (nums[1] || 1) - 1);
91
+ break;
92
+ }
93
+ case 'K': { // erase in line
94
+ const mode = nums[0] || 0;
95
+ if (mode === 0) clearRow(row, col, cols - 1);
96
+ else if (mode === 1) clearRow(row, 0, col);
97
+ else clearRow(row, 0, cols - 1);
98
+ break;
99
+ }
100
+ case 'J': { // erase in display
101
+ const mode = nums[0] || 0;
102
+ if (mode === 0) {
103
+ clearRow(row, col, cols - 1);
104
+ for (let r = row + 1; r < rows; r += 1) clearRow(r, 0, cols - 1);
105
+ } else if (mode === 2) {
106
+ for (let r = 0; r < rows; r += 1) clearRow(r, 0, cols - 1);
107
+ }
108
+ break;
109
+ }
110
+ default:
111
+ break; // SGR ('m') and anything else - no visible grid effect
112
+ }
113
+ }
114
+ // Private-mode toggles (alternate screen, mouse tracking) have no
115
+ // visible grid effect either - nothing to do for them.
116
+ i = match.index + match[0].length;
117
+ continue;
118
+ }
119
+ if (ch === '\x1b') {
120
+ i += 1; // a lone/malformed escape - skip it, don't loop forever
121
+ continue;
122
+ }
123
+ if (ch === '\n') {
124
+ newline();
125
+ i += 1;
126
+ continue;
127
+ }
128
+ if (ch === '\r') {
129
+ col = 0;
130
+ i += 1;
131
+ continue;
132
+ }
133
+ putChar(ch);
134
+ i += 1;
135
+ }
136
+
137
+ return screen.map((r) => r.join('').replace(/\s+$/, ''));
138
+ }