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,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
|
+
}
|