eklavya 1.71.0 → 1.71.2
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/README.md
CHANGED
|
@@ -52,7 +52,7 @@ Code's learning loop.
|
|
|
52
52
|
|---|---|
|
|
53
53
|
| Learner state and concepts | `get_learner_profile`, `log_session_concepts`, `upsert_concepts`, `get_concept_graph` |
|
|
54
54
|
| Questions and assessment | `get_session_quiz_plan`, `record_attempt`, `get_gate_status` |
|
|
55
|
-
| Quiz side panel (experimental) | `present_question` for the model; `panel_sync` and `panel_answer`, called only by the panel mod, never by the model |
|
|
55
|
+
| Quiz side panel (experimental) | `present_question` for the model; `panel_sync` and `panel_answer`, called only by the panel mod, never by the model; its header stacks the mark above the topic in a pane under 60 cells wide |
|
|
56
56
|
| Settings | `get_config`, `set_config` |
|
|
57
57
|
| Find and read memory | `memory_search`, `memory_get`, `memory_timeline`, `memory_file_history`, `memory_status` |
|
|
58
58
|
| Maintain memory | `memory_write`, `memory_correct`, `memory_delete`, `memory_collections` |
|
|
@@ -83,7 +83,7 @@ level.
|
|
|
83
83
|
right one, 2 for a near miss, 1 for a misconception), the right one in the
|
|
84
84
|
plan's `answer_position` slot, and an explanation. It stores the question and
|
|
85
85
|
returns at once; it writes no attempt. The panel records the answer through
|
|
86
|
-
`panel_answer` with the same code `record_attempt` uses, exactly once. `kind: "dont_know"` (the panel's **Teach me** button) records `dont_know` with grade 0, replies with the right answer and `taught: true`, and always carries `explain`. On a miss with `explain_on_wrong` on, its reply carries the same `explain` block `record_attempt` returns, and the panel starts the explainer agent from it. `present_question` sends a question back once with `conspicuous_options` (nothing stored) when one option's label plus description outruns the next by a few words or a description explains itself, and presents the same stem as written on the second call. It returns `panel_disabled` while `quiz.panel` is off, and
|
|
86
|
+
`panel_answer` with the same code `record_attempt` uses, exactly once. `kind: "dont_know"` (the panel's **New to me — Teach me** button) records `dont_know` with grade 0, replies with the right answer and `taught: true`, and always carries `explain`. On a miss with `explain_on_wrong` on, its reply carries the same `explain` block `record_attempt` returns, and the panel starts the explainer agent from it. `present_question` sends a question back once with `conspicuous_options` (nothing stored) when one option's label plus description outruns the next by a few words or a description explains itself, and presents the same stem as written on the second call. It returns `panel_disabled` while `quiz.panel` is off, and
|
|
87
87
|
`panel_sync` returns `{disabled: true}` and expires the session's open question without recording an attempt, so turning the panel back on cannot revive it; `panel_answer` answers only a question
|
|
88
88
|
that already exists. A round the learner asked for (a topic, `max`) is remembered per session: `present_question` takes each question off it and sets the last one's `more` false, and `get_session_quiz_plan` with `resume_round: true` plans what is left. This is
|
|
89
89
|
a Claude Code mod feature: a standalone MCP client has the tools but not the
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eklavya",
|
|
3
3
|
"displayName": "Eklavya",
|
|
4
|
-
"version": "1.71.
|
|
4
|
+
"version": "1.71.2",
|
|
5
5
|
"description": "Learn while your agent works. Turns coding-agent generation time into adaptive, Socratic learning grounded in the code being written.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Ajay Kumar"
|
|
@@ -22,6 +22,8 @@ export const TIER: Record<number, string> = { 1: 'recall', 2: 'mechanism', 3: 'j
|
|
|
22
22
|
export const BRAND = '#79D5C4'
|
|
23
23
|
/** The fill and text of the picked answer: set together so the contrast holds in a light or a dark theme. */
|
|
24
24
|
export const PICKED = { bg: '#1E5A52', text: '#FFFFFF', note: '#C9E8E3' }
|
|
25
|
+
/** Teach me is Claude's orange (the logo's mascot), fill and border. The text is dark, as white on that orange is too faint at body size. */
|
|
26
|
+
export const TEACH = { bg: '#D97757', text: '#1F1E1D' }
|
|
25
27
|
|
|
26
28
|
/**
|
|
27
29
|
* The logo: the Claude mascot (orange) holding an Eklavya bow and arrow (white), drawn on a
|
|
@@ -106,14 +108,14 @@ export function codify(text: string): string {
|
|
|
106
108
|
.join('')
|
|
107
109
|
}
|
|
108
110
|
|
|
109
|
-
/** Block Markdown
|
|
110
|
-
const
|
|
111
|
+
/** Block Markdown a heading mark would break: a fence, heading, list, quote, or bold already in it. */
|
|
112
|
+
const NOT_HEADING = /```|\*\*|^\s*(?:#|[-*+>]|\d+[.)])\s/m
|
|
111
113
|
|
|
112
|
-
/**
|
|
113
|
-
export function
|
|
114
|
+
/** Makes each plain paragraph of a question a heading, so the app draws it larger and bold; a heading is one line, so its line breaks become spaces. */
|
|
115
|
+
export function headingParagraphs(text: string): string {
|
|
114
116
|
return text
|
|
115
117
|
.split(/\n{2,}/)
|
|
116
|
-
.map(p => (p.trim() && !
|
|
118
|
+
.map(p => (p.trim() && !NOT_HEADING.test(p) ? `### ${p.trim().replace(/\s*\n\s*/g, ' ')}` : p))
|
|
117
119
|
.join('\n\n')
|
|
118
120
|
}
|
|
119
121
|
|
|
@@ -132,7 +134,7 @@ export const STR = {
|
|
|
132
134
|
brand: 'Eklavya',
|
|
133
135
|
submit: 'Submit answer',
|
|
134
136
|
skip: 'Skip question',
|
|
135
|
-
teach: 'Teach me',
|
|
137
|
+
teach: 'New to me — Teach me',
|
|
136
138
|
next: 'Next question',
|
|
137
139
|
done: 'Done',
|
|
138
140
|
autoClose: 'Closes by itself in 15 seconds.',
|
|
@@ -159,7 +161,6 @@ export const STR = {
|
|
|
159
161
|
askedOther: 'Eklavya: a note for Claude',
|
|
160
162
|
focus: 'Ctrl+X then Tab moves the keys here.',
|
|
161
163
|
keys: '1–4 pick · o other · s submit · k skip · t teach me · Esc back to prompt',
|
|
162
|
-
keysApp: '1–4 pick · o other · s submit · k skip · t teach me',
|
|
163
164
|
off: 'The quiz panel is off. Turn it on with: eklavya config set quiz.panel true',
|
|
164
165
|
} as const
|
|
165
166
|
|
|
@@ -202,11 +203,11 @@ export function explainerBrief(x: ExplainBlock): string {
|
|
|
202
203
|
export const answerWas = (label: string): string => `The answer was: ${label}`
|
|
203
204
|
|
|
204
205
|
/**
|
|
205
|
-
* The
|
|
206
|
-
*
|
|
207
|
-
*
|
|
206
|
+
* The key hints. Only the terminal gets them: there Ctrl+X then Tab moves the keys into the pane. The
|
|
207
|
+
* desktop app keeps typing in its prompt even after a click on the pane (seen 2026-10-16), so a mod's
|
|
208
|
+
* hotkeys never reach it there, and it shows no keys at all.
|
|
208
209
|
*/
|
|
209
|
-
export const hintLines = (surface: string): string[] => (surface === 'terminal' ? [STR.focus, STR.keys] : [
|
|
210
|
+
export const hintLines = (surface: string): string[] => (surface === 'terminal' ? [STR.focus, STR.keys] : [])
|
|
210
211
|
|
|
211
212
|
export const unplacedNotice = (): string => `A question is waiting. ${REOPEN} to open it.`
|
|
212
213
|
export const levelLine = (level: string, project: string): string => `You've cleared ${level} on ${project}.`
|
|
@@ -4,6 +4,7 @@ import type { EngineInterface, Register } from 'claude-code'
|
|
|
4
4
|
import {
|
|
5
5
|
BRAND,
|
|
6
6
|
PICKED,
|
|
7
|
+
TEACH,
|
|
7
8
|
EMPTY,
|
|
8
9
|
EXPLAINER,
|
|
9
10
|
NEXT_PROMPT,
|
|
@@ -18,7 +19,7 @@ import {
|
|
|
18
19
|
gradingRequest,
|
|
19
20
|
logoRows,
|
|
20
21
|
boxRows,
|
|
21
|
-
|
|
22
|
+
headingParagraphs,
|
|
22
23
|
plainLabel,
|
|
23
24
|
levelLine,
|
|
24
25
|
parseVerdict,
|
|
@@ -42,6 +43,9 @@ import type { PanelPending, PanelQuestion, PanelState } from './types'
|
|
|
42
43
|
* for the next sync.
|
|
43
44
|
*/
|
|
44
45
|
|
|
46
|
+
// Below this many cells across, the header stacks the mark over the topic (the pane reports bodyColumns).
|
|
47
|
+
const NARROW = 60
|
|
48
|
+
|
|
45
49
|
const REF = { plugin: 'eklavya', key: 'quiz' } as const
|
|
46
50
|
const quiz = atom(REF, EMPTY)
|
|
47
51
|
|
|
@@ -364,8 +368,8 @@ export const register: Register = on => {
|
|
|
364
368
|
const s: PanelState = await read($, quiz)
|
|
365
369
|
const q = s.question
|
|
366
370
|
|
|
367
|
-
// Any surface but the terminal is the desktop app's: it sets the question
|
|
368
|
-
// apart and draws the mark in boxes. The terminal keeps its text layout.
|
|
371
|
+
// Any surface but the terminal is the desktop app's: it sets the question as a heading, spaces the
|
|
372
|
+
// answers apart and draws the mark in boxes. The terminal keeps its text layout.
|
|
369
373
|
const app = e.surface !== 'terminal'
|
|
370
374
|
// The mark is drawn in text, as Claude Code draws its own, so it shows in every terminal. The
|
|
371
375
|
// desktop app sets text in a proportional font with its own line height, so glyph cells do not
|
|
@@ -396,19 +400,23 @@ export const register: Register = on => {
|
|
|
396
400
|
))}
|
|
397
401
|
</Box>
|
|
398
402
|
)
|
|
399
|
-
// A list tile: the mark on the left
|
|
403
|
+
// A list tile: the mark on the left, the name over the topic beside it. A pane narrower than
|
|
404
|
+
// NARROW cells stacks them, so the topic gets the full width instead of a sliver.
|
|
405
|
+
const stacked = ((e.props as { bodyColumns?: number }).bodyColumns ?? Infinity) < NARROW
|
|
400
406
|
const head = (subtitle?: string) => (
|
|
401
|
-
<Box flexDirection=
|
|
407
|
+
<Box flexDirection={stacked ? 'column' : 'row'} alignItems={stacked ? 'flex-start' : 'center'} gap={2}>
|
|
402
408
|
{logo}
|
|
403
409
|
<Box flexDirection="column" flexShrink={1}>
|
|
404
410
|
<Text bold color={BRAND}>
|
|
405
411
|
{WORDMARK}
|
|
406
412
|
</Text>
|
|
407
|
-
{subtitle ? <Text>{subtitle}</Text> : null}
|
|
413
|
+
{subtitle ? <Text wrap="wrap">{subtitle}</Text> : null}
|
|
408
414
|
</Box>
|
|
409
415
|
</Box>
|
|
410
416
|
)
|
|
411
|
-
|
|
417
|
+
// The desktop app never sends the pane its keys (see hintLines), so it gets no hotkeys and no hints.
|
|
418
|
+
const hk = (k: string) => (app ? undefined : k)
|
|
419
|
+
const hint = app ? null : (
|
|
412
420
|
<Box flexDirection="column">
|
|
413
421
|
{hintLines(e.surface).map(line => (
|
|
414
422
|
<Text key={line} dimColor>
|
|
@@ -418,8 +426,9 @@ export const register: Register = on => {
|
|
|
418
426
|
</Box>
|
|
419
427
|
)
|
|
420
428
|
// One answer: a bordered block. The Button holds the whole answer text, so a press anywhere on it
|
|
421
|
-
// selects; the border and
|
|
422
|
-
// the text more room inside the border; the
|
|
429
|
+
// selects; the border and fill show the pick, and nothing implies it is
|
|
430
|
+
// right. The desktop app gives the text more room inside the border; the padding itself is not
|
|
431
|
+
// pressable, since a Button takes none. The terminal keeps its tight cards.
|
|
423
432
|
// Other is never filled: the learner types inside it, in the host's own text colour, which a fixed fill could hide.
|
|
424
433
|
const card = (id: string, selected: boolean, button: unknown, body: unknown[], roomy = false) => (
|
|
425
434
|
<Box
|
|
@@ -455,17 +464,17 @@ export const register: Register = on => {
|
|
|
455
464
|
)
|
|
456
465
|
}
|
|
457
466
|
|
|
458
|
-
const stem = <Markdown text={app ?
|
|
467
|
+
const stem = <Markdown text={app ? headingParagraphs(codify(q.stem)) : codify(q.stem)} />
|
|
459
468
|
|
|
460
469
|
if (s.step === 'feedback' || s.step === 'skipped') {
|
|
461
470
|
const r = s.result
|
|
462
471
|
if (s.step === 'skipped' || !r) {
|
|
463
|
-
return frame(<Text>{STR.skipped}</Text>, q.more ? null : <Text dimColor>{STR.autoClose}</Text>, <Button key="done" label={STR.done} variant="primary" hotkey=
|
|
472
|
+
return frame(<Text>{STR.skipped}</Text>, q.more ? null : <Text dimColor>{STR.autoClose}</Text>, <Button key="done" label={STR.done} variant="primary" hotkey={hk('d')} onPress={() => done($)} />)
|
|
464
473
|
}
|
|
465
474
|
const right = r.correct === true
|
|
466
475
|
return frame(
|
|
467
476
|
stem,
|
|
468
|
-
<Text bold color={right ? 'suggestion' : r.taught ?
|
|
477
|
+
<Text bold color={right ? 'suggestion' : r.taught ? BRAND : 'error'}>
|
|
469
478
|
{right ? STR.correct : r.taught ? STR.taught : STR.wrong}
|
|
470
479
|
</Text>,
|
|
471
480
|
right || !r.correct_label ? null : <Text>{answerWas(r.correct_label)}</Text>,
|
|
@@ -474,8 +483,8 @@ export const register: Register = on => {
|
|
|
474
483
|
r.explain ? <Text dimColor>{r.taught ? STR.taughtExplainer : STR.explainer}</Text> : null,
|
|
475
484
|
q.more ? null : <Text dimColor>{STR.autoClose}</Text>,
|
|
476
485
|
<Box flexDirection="row" gap={2}>
|
|
477
|
-
{q.more ? <Button key="next" label={STR.next} hotkey=
|
|
478
|
-
<Button key="done" label={STR.done} variant="primary" hotkey=
|
|
486
|
+
{q.more ? <Button key="next" label={STR.next} hotkey={hk('n')} onPress={() => askNext($)} /> : null}
|
|
487
|
+
<Button key="done" label={STR.done} variant="primary" hotkey={hk('d')} onPress={() => done($)} />
|
|
479
488
|
</Box>,
|
|
480
489
|
)
|
|
481
490
|
}
|
|
@@ -485,16 +494,20 @@ export const register: Register = on => {
|
|
|
485
494
|
const hasDraft = s.draft.other ? s.draft.text.trim().length > 0 : s.draft.picked !== null
|
|
486
495
|
// The answer and its note are the Button's own inline Text, so a press anywhere on them selects.
|
|
487
496
|
// Markdown marks cannot show inside a Button, so the code and bold marks are dropped from both.
|
|
497
|
+
// The pick shows by the card's border and fill, never a mark in the text, so picking moves nothing.
|
|
498
|
+
// On the desktop app the note starts where the answer does; the terminal indents it under the `1: `
|
|
499
|
+
// its plain Button draws.
|
|
500
|
+
// `wrap` asks the app to wrap a long answer rather than cut it off.
|
|
488
501
|
const answer = (id: string, key: string, hotkey: string, label: string, chosen: boolean, note?: string) => (
|
|
489
502
|
<Button
|
|
490
503
|
key={key}
|
|
491
504
|
plain
|
|
492
|
-
hotkey={hotkey}
|
|
505
|
+
hotkey={hk(hotkey)}
|
|
493
506
|
label={label}
|
|
494
507
|
onPress={() => (locked ? undefined : id === 'other' ? chooseOther($) : pick($, id))}
|
|
495
508
|
>
|
|
496
|
-
<Text bold={chosen} color={chosen && id !== 'other' ? PICKED.text : undefined}>{
|
|
497
|
-
{note ? <Text dimColor={!chosen} color={chosen ? PICKED.note : undefined}>{`\n ${note}`}</Text> : null}
|
|
509
|
+
<Text wrap="wrap" bold={chosen} color={chosen && id !== 'other' ? PICKED.text : undefined}>{label}</Text>
|
|
510
|
+
{note ? <Text wrap="wrap" dimColor={!chosen} color={chosen ? PICKED.note : undefined}>{`\n${app ? '' : ' '}${note}`}</Text> : null}
|
|
498
511
|
</Button>
|
|
499
512
|
)
|
|
500
513
|
const options = q.options.map((o, i) => {
|
|
@@ -509,7 +522,7 @@ export const register: Register = on => {
|
|
|
509
522
|
|
|
510
523
|
return frame(
|
|
511
524
|
stem,
|
|
512
|
-
<Box flexDirection="column" gap={app ? 2 : 0}>
|
|
525
|
+
<Box flexDirection="column" gap={app ? 2 : 0} marginTop={app ? 1 : 0}>
|
|
513
526
|
{options}
|
|
514
527
|
{card(
|
|
515
528
|
'other',
|
|
@@ -538,14 +551,29 @@ export const register: Register = on => {
|
|
|
538
551
|
s.step === 'awaiting' && s.message ? <Text color="warning">{s.message}</Text> : null,
|
|
539
552
|
<Box flexDirection="row" gap={2}>
|
|
540
553
|
{locked ? null : s.step === 'error' ? (
|
|
541
|
-
<Button key="retry" label={STR.retry} variant="primary" hotkey=
|
|
554
|
+
<Button key="retry" label={STR.retry} variant="primary" hotkey={hk('r')} onPress={() => submit($, null)} />
|
|
542
555
|
) : (
|
|
543
|
-
<Button key="submit" label={STR.submit} variant="primary" dimColor={!hasDraft} hotkey=
|
|
556
|
+
<Button key="submit" label={STR.submit} variant="primary" dimColor={!hasDraft} hotkey={hk('s')} onPress={() => submit($, null)} />
|
|
544
557
|
)}
|
|
545
|
-
{locked ? null : <Button key="
|
|
546
|
-
{locked ? null : <Button key="skip" label={STR.skip} dimColor hotkey="k" onPress={() => skip($)} />}
|
|
558
|
+
{locked ? null : <Button key="skip" label={STR.skip} dimColor hotkey={hk('k')} onPress={() => skip($)} />}
|
|
547
559
|
{s.step === 'error' ? <Button key="close" label={STR.close} onPress={() => $.ui.close({ id: PANE })} /> : null}
|
|
548
560
|
</Box>,
|
|
561
|
+
// Teach me has a row of its own, under Submit. A Button takes no fill, and a desktop draws a plain
|
|
562
|
+
// one without its grey chrome: the orange box is the button's body, and as a column it
|
|
563
|
+
// stretches the Button across its width, so all but the thin padding is pressable.
|
|
564
|
+
locked ? null : app ? (
|
|
565
|
+
<Box key="teach-box" flexDirection="column" backgroundColor={TEACH.bg} borderStyle="round" borderColor={TEACH.bg} paddingX={1} paddingY={0.5}>
|
|
566
|
+
<Button key="teach" plain label={STR.teach} onPress={() => teach($)}>
|
|
567
|
+
<Text color={TEACH.text}>{STR.teach}</Text>
|
|
568
|
+
</Button>
|
|
569
|
+
</Box>
|
|
570
|
+
) : (
|
|
571
|
+
<Box key="teach-row" flexDirection="row">
|
|
572
|
+
<Button key="teach" label={STR.teach} hotkey={hk('t')} onPress={() => teach($)}>
|
|
573
|
+
<Text color={TEACH.text} backgroundColor={TEACH.bg}>{STR.teach}</Text>
|
|
574
|
+
</Button>
|
|
575
|
+
</Box>
|
|
576
|
+
),
|
|
549
577
|
)
|
|
550
578
|
})
|
|
551
579
|
}
|
package/package.json
CHANGED