eklavya 1.51.0 → 1.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/dist/assets/dashboard.html +1 -1
- package/dist/assets/tutor/SKILL.md +18 -16
- package/dist/assets/tutor/references/panel.md +40 -0
- package/dist/cli.js +6 -3
- package/dist/cli.js.map +1 -1
- package/dist/config-path.js +1 -0
- package/dist/config-path.js.map +1 -1
- package/dist/config.js +3 -1
- package/dist/config.js.map +1 -1
- package/dist/dashboard.js +2 -0
- package/dist/dashboard.js.map +1 -1
- package/dist/hooks/checkpoint-quiz.js +27 -7
- package/dist/hooks/checkpoint-quiz.js.map +1 -1
- package/dist/hooks/delegate-nudge.js +1 -1
- package/dist/hooks/delegate-nudge.js.map +1 -1
- package/dist/hooks/delegation-lib.js +14 -9
- package/dist/hooks/delegation-lib.js.map +1 -1
- package/dist/hooks/prompt-submit-nudge.js +1 -1
- package/dist/hooks/prompt-submit-nudge.js.map +1 -1
- package/dist/hooks/session-start.js +1 -1
- package/dist/hooks/session-start.js.map +1 -1
- package/dist/hooks/stop-quiz-check.js +6 -0
- package/dist/hooks/stop-quiz-check.js.map +1 -1
- package/dist/migrations/025_panel_questions.sql +43 -0
- package/dist/panel-state.js +117 -0
- package/dist/panel-state.js.map +1 -0
- package/dist/panel.js +241 -0
- package/dist/panel.js.map +1 -0
- package/dist/plugin/.claude-plugin/plugin.json +3 -2
- package/dist/plugin/agents/tutor.md +3 -1
- package/dist/plugin/hooks/CLAUDE.md +18 -0
- package/dist/plugin/hooks/hooks.json +44 -11
- package/dist/plugin/hooks/panel/model.ts +132 -0
- package/dist/plugin/hooks/panel/register.tsx +479 -0
- package/dist/plugin/hooks/panel/types/index.d.ts +61 -0
- package/dist/plugin/skills/CLAUDE.md +6 -0
- package/dist/plugin/skills/quiz/SKILL.md +3 -0
- package/dist/plugin/skills/tutor/SKILL.md +18 -16
- package/dist/plugin/skills/tutor/references/panel.md +40 -0
- package/dist/store.js +6 -0
- package/dist/store.js.map +1 -1
- package/dist/tools/config_tools.js +2 -1
- package/dist/tools/config_tools.js.map +1 -1
- package/dist/tools/get_session_quiz_plan.js +54 -8
- package/dist/tools/get_session_quiz_plan.js.map +1 -1
- package/dist/tools/index.js +4 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/panel_tools.js +72 -0
- package/dist/tools/panel_tools.js.map +1 -0
- package/dist/tools/record_attempt.js +191 -185
- package/dist/tools/record_attempt.js.map +1 -1
- package/dist/user-skill/eklavya/SKILL.md +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { PanelQuestion, PanelState } from './types'
|
|
2
|
+
|
|
3
|
+
/** The pane's id, as `$.ui.open` and the `ui.render` hook both name it. */
|
|
4
|
+
export const PANE = 'eklavya-quiz'
|
|
5
|
+
|
|
6
|
+
/** The command that opens the pane when it was hidden or could not be seated. */
|
|
7
|
+
export const REOPEN = '/eklavya-panel'
|
|
8
|
+
|
|
9
|
+
export const EMPTY: PanelState = {
|
|
10
|
+
step: 'none',
|
|
11
|
+
question: null,
|
|
12
|
+
draft: { picked: null, other: false, text: '' },
|
|
13
|
+
pending: null,
|
|
14
|
+
result: null,
|
|
15
|
+
message: null,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Tier names as the dashboard shows them (`TIER` in `assets/dashboard.html`; a test keeps the two equal). */
|
|
19
|
+
export const TIER: Record<number, string> = { 1: 'recall', 2: 'mechanism', 3: 'judgement', 4: 'failure modes', 5: 'design' }
|
|
20
|
+
|
|
21
|
+
/** Every sentence the learner reads, in one place. The handoff's table, verbatim. */
|
|
22
|
+
export const STR = {
|
|
23
|
+
brand: 'Eklavya',
|
|
24
|
+
powered: 'Powered by Claude',
|
|
25
|
+
submit: 'Submit answer',
|
|
26
|
+
skip: 'Skip question',
|
|
27
|
+
next: 'Next question',
|
|
28
|
+
done: 'Done',
|
|
29
|
+
retry: 'Retry',
|
|
30
|
+
close: 'Close',
|
|
31
|
+
other: 'Other — explain in your own words',
|
|
32
|
+
otherPlaceholder: 'Your answer in your own words',
|
|
33
|
+
loading: 'Loading your question…',
|
|
34
|
+
grading: 'Checking your answer…',
|
|
35
|
+
correct: 'Correct',
|
|
36
|
+
wrong: 'Needs another look',
|
|
37
|
+
skipped: "Skipped. We won't ask this one again.",
|
|
38
|
+
empty: 'No question right now',
|
|
39
|
+
unreachable: "We couldn't reach Eklavya. Your answer is saved here — Retry.",
|
|
40
|
+
stale: 'This question belongs to another session and was closed.',
|
|
41
|
+
nothingTyped: 'Write your answer first, or pick one of the options.',
|
|
42
|
+
graderFailed: "We couldn't check that answer. Your text is saved here — Retry.",
|
|
43
|
+
explainer: 'A page explaining this is being written.',
|
|
44
|
+
off: 'The quiz panel is off. Turn it on with: eklavya config set quiz.panel true',
|
|
45
|
+
} as const
|
|
46
|
+
|
|
47
|
+
export const answerWas = (label: string): string => `The answer was: ${label}`
|
|
48
|
+
export const unplacedNotice = (): string => `A question is waiting. ${REOPEN} to open it.`
|
|
49
|
+
export const levelLine = (level: string, project: string): string => `You've cleared ${level} on ${project}.`
|
|
50
|
+
|
|
51
|
+
/** `<concept name> · <tier label>`, the tier named the way the dashboard names it. */
|
|
52
|
+
export function topicLabel(q: Pick<PanelQuestion, 'concept_name' | 'tier'>): string {
|
|
53
|
+
const tier = TIER[q.tier]
|
|
54
|
+
return `${q.concept_name} · T${q.tier}${tier ? ` ${tier}` : ''}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The project's folder name, or a plain phrase when the question belongs to no repository. */
|
|
58
|
+
export function projectName(repo: string): string {
|
|
59
|
+
const last = repo.split(/[\\/]/).filter(Boolean).pop()
|
|
60
|
+
return repo === '*' || !last ? 'this project' : last
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The grading rubric for a typed answer: `skills/tutor/references/grading.md`'s
|
|
65
|
+
* free-recall scale, copied row for row (a test fails if either side changes).
|
|
66
|
+
* A typed answer is free recall, so it is not capped at 4.
|
|
67
|
+
*/
|
|
68
|
+
export const RUBRIC = [
|
|
69
|
+
'0 | no answer — either a blank ("I don\'t know") or a decline. Pass `outcome` to say which',
|
|
70
|
+
'1 | wrong, and the misconception is load-bearing',
|
|
71
|
+
'2 | wrong, but the shape of the idea is there',
|
|
72
|
+
'3 | correct, but hesitant or incomplete — got there slowly',
|
|
73
|
+
'4 | correct and clean',
|
|
74
|
+
'5 | correct, and explained *why*, or caught a nuance you didn\'t ask for',
|
|
75
|
+
].join('\n')
|
|
76
|
+
|
|
77
|
+
export const GRADER_MODEL = 'haiku'
|
|
78
|
+
|
|
79
|
+
/** One model call with no history: it gets the question, the key and the words, nothing else. */
|
|
80
|
+
export function gradingRequest(q: PanelQuestion, correctLabel: string, typed: string): { model: string; system: string; prompt: string } {
|
|
81
|
+
return {
|
|
82
|
+
model: GRADER_MODEL,
|
|
83
|
+
system:
|
|
84
|
+
'You grade one answer a developer typed in their own words to a multiple-choice question, on a 0-5 scale. ' +
|
|
85
|
+
'Before you pick a number, state to yourself what in their answer justifies it; generosity is not kindness. ' +
|
|
86
|
+
'Reply with one JSON object and nothing else: {"grade": 0-5, "outcome": "answered" or "dont_know", "feedback": "..."}. ' +
|
|
87
|
+
'Use "dont_know" only when they said they do not know, with grade 0. ' +
|
|
88
|
+
'Feedback is at most two plain sentences saying what was right or missing.\n\nGrades:\n' +
|
|
89
|
+
RUBRIC,
|
|
90
|
+
prompt: [
|
|
91
|
+
`Question: ${q.stem}`,
|
|
92
|
+
`Options: ${q.options.map((o, i) => `${String.fromCharCode(65 + i)}. ${o.label}`).join(' | ')}`,
|
|
93
|
+
`The right answer: ${correctLabel}`,
|
|
94
|
+
`Their answer, in their own words: ${typed}`,
|
|
95
|
+
].join('\n'),
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type Verdict = { grade: number; outcome: 'answered' | 'dont_know'; feedback: string }
|
|
100
|
+
|
|
101
|
+
const FEEDBACK_MAX = 1000
|
|
102
|
+
|
|
103
|
+
/** The grader's reply, or null when it is anything but a well-formed verdict. Nothing is recorded from null. */
|
|
104
|
+
export function parseVerdict(text: string): Verdict | null {
|
|
105
|
+
const found = /\{[\s\S]*\}/.exec(text)
|
|
106
|
+
if (!found) return null
|
|
107
|
+
let raw: unknown
|
|
108
|
+
try {
|
|
109
|
+
raw = JSON.parse(found[0])
|
|
110
|
+
} catch {
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
if (typeof raw !== 'object' || raw === null) return null
|
|
114
|
+
const v = raw as Record<string, unknown>
|
|
115
|
+
const feedback = typeof v.feedback === 'string' ? v.feedback.trim() : ''
|
|
116
|
+
if (typeof v.grade !== 'number' || !Number.isInteger(v.grade) || v.grade < 0 || v.grade > 5) return null
|
|
117
|
+
if (v.outcome !== 'answered' && v.outcome !== 'dont_know') return null
|
|
118
|
+
if (v.outcome === 'dont_know' && v.grade !== 0) return null
|
|
119
|
+
if (!feedback || feedback.length > FEEDBACK_MAX) return null
|
|
120
|
+
return { grade: v.grade, outcome: v.outcome, feedback }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The text of an MCP tool result's first text block, parsed as the JSON every Eklavya tool returns. */
|
|
124
|
+
export function payloadOf(blocks: readonly { type: string; text?: string }[]): Record<string, any> {
|
|
125
|
+
const text = blocks.find((b) => b.type === 'text')?.text
|
|
126
|
+
if (!text) throw new Error('empty reply from the Eklavya server')
|
|
127
|
+
return JSON.parse(text) as Record<string, any>
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Prompts the mod queues for the model. Short: the model reads the plan for the rest. */
|
|
131
|
+
export const NEXT_PROMPT =
|
|
132
|
+
'The learner pressed Next question in the Eklavya panel. Call get_session_quiz_plan with resume_round: true and present the next question of this round with present_question, then carry on.'
|
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import { atom, read, update } from 'claude-code'
|
|
2
|
+
import type { EngineInterface, Register } from 'claude-code'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
EMPTY,
|
|
6
|
+
NEXT_PROMPT,
|
|
7
|
+
PANE,
|
|
8
|
+
REOPEN,
|
|
9
|
+
STR,
|
|
10
|
+
answerWas,
|
|
11
|
+
gradingRequest,
|
|
12
|
+
levelLine,
|
|
13
|
+
parseVerdict,
|
|
14
|
+
payloadOf,
|
|
15
|
+
projectName,
|
|
16
|
+
topicLabel,
|
|
17
|
+
unplacedNotice,
|
|
18
|
+
} from './model'
|
|
19
|
+
import type { PanelPending, PanelQuestion, PanelState } from './types'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The Eklavya quiz panel: a pane that shows the question the tutor model handed
|
|
23
|
+
* to `present_question`, takes the learner's answer and sends it to the Eklavya
|
|
24
|
+
* server to be graded and recorded once.
|
|
25
|
+
*
|
|
26
|
+
* Everything that matters lives in the server and its database: the question,
|
|
27
|
+
* its key, the once-only guard, the grade. This module draws and relays. It
|
|
28
|
+
* never holds the answer key, and any failure leaves the host as it was: a
|
|
29
|
+
* handler that throws is skipped, and the question stays waiting in the database
|
|
30
|
+
* for the next sync.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const REF = { plugin: 'eklavya', key: 'quiz' } as const
|
|
34
|
+
const quiz = atom(REF, EMPTY)
|
|
35
|
+
|
|
36
|
+
/** The server's name as /mcp lists it: plugin-scoped first, then the spelling a hand-installed server has. */
|
|
37
|
+
const SERVERS = ['plugin:eklavya:eklavya', 'plugin_eklavya_eklavya', 'eklavya']
|
|
38
|
+
|
|
39
|
+
/** How often the heartbeat may be refreshed from busy events (tool calls, prompts). The planner trusts it for 90 s. */
|
|
40
|
+
const HEARTBEAT_MS = 30_000
|
|
41
|
+
|
|
42
|
+
/** Module variables are lost on a reload, which is fine: each is only a guard against doing a thing twice. */
|
|
43
|
+
let lastSyncAt = 0
|
|
44
|
+
let openedFor: string | null = null
|
|
45
|
+
let working: string | null = null
|
|
46
|
+
let busy = false
|
|
47
|
+
/** Bumped when the session ends: a submit that began before it is stale and must write nothing. */
|
|
48
|
+
let epoch = 0
|
|
49
|
+
let explainedAttempt = 0
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One call to an Eklavya tool through the host's own connection, as the parsed
|
|
53
|
+
* JSON every Eklavya tool returns. The first call after a reload can be refused
|
|
54
|
+
* by the host's auto mode classifier ("gave no verdict ... issue the action
|
|
55
|
+
* again once"); asking once more is what it says and what works.
|
|
56
|
+
*/
|
|
57
|
+
async function call($: EngineInterface, tool: string, args: Record<string, unknown>): Promise<Record<string, any>> {
|
|
58
|
+
for (const server of working ? [working] : SERVERS) {
|
|
59
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
60
|
+
try {
|
|
61
|
+
const reply = await $.mcp.call(server, tool, args)
|
|
62
|
+
working = server
|
|
63
|
+
return payloadOf(reply.content)
|
|
64
|
+
} catch (err) {
|
|
65
|
+
const why = String(err)
|
|
66
|
+
if (why.includes('no connected MCP tool')) break
|
|
67
|
+
if (!why.includes('gave no verdict')) throw err
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
throw new Error('the Eklavya server is not connected')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Where this session is, as the server needs it. */
|
|
75
|
+
async function whereAmI($: EngineInterface) {
|
|
76
|
+
const [sessionId, cwd, surfaces, version] = await Promise.all([
|
|
77
|
+
$.session.id(),
|
|
78
|
+
$.session.cwd(),
|
|
79
|
+
$.session.surfaces(),
|
|
80
|
+
$.session.version(),
|
|
81
|
+
])
|
|
82
|
+
return { session_id: sessionId, cwd, host: { surface: surfaces[0] ?? 'terminal', version: version.version } }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Asks the server whether a question is waiting and shows it. Also the mod's
|
|
87
|
+
* heartbeat: the planner chooses this pane only while a sync is fresh.
|
|
88
|
+
*/
|
|
89
|
+
async function sync($: EngineInterface): Promise<'disabled' | 'none' | 'question'> {
|
|
90
|
+
const where = await whereAmI($)
|
|
91
|
+
const out = await call($, 'panel_sync', where)
|
|
92
|
+
// Only a sync that reached the server counts as a heartbeat. The server can
|
|
93
|
+
// still be starting when the session does, and a failed attempt must not hold
|
|
94
|
+
// the next one back.
|
|
95
|
+
lastSyncAt = Date.now()
|
|
96
|
+
|
|
97
|
+
if (out.disabled) {
|
|
98
|
+
await update($, quiz, () => EMPTY)
|
|
99
|
+
await $.ui.close({ id: PANE })
|
|
100
|
+
openedFor = null
|
|
101
|
+
return 'disabled'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (out.none) {
|
|
105
|
+
// The question that was here is gone: answered elsewhere, expired, or this
|
|
106
|
+
// is a new session. A result the learner is still reading is left alone.
|
|
107
|
+
openedFor = null
|
|
108
|
+
// An answer in flight has just closed the row, so "none" is expected while it
|
|
109
|
+
// is still being sent: keep the question and the answer until it resolves, or
|
|
110
|
+
// a lost reply leaves nothing to Retry (Retry returns the stored result).
|
|
111
|
+
if (!busy) {
|
|
112
|
+
await update($, quiz, (s: PanelState) =>
|
|
113
|
+
s.step === 'awaiting' || s.step === 'grading' || s.step === 'loading' ? { ...EMPTY, message: s.step === 'loading' ? null : s.message } : s,
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
return 'none'
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const question = out as unknown as PanelQuestion
|
|
120
|
+
await update($, quiz, (s: PanelState) =>
|
|
121
|
+
s.question?.question_id === question.question_id && s.step !== 'none' && s.step !== 'loading'
|
|
122
|
+
? { ...s, question }
|
|
123
|
+
: { ...EMPTY, step: 'awaiting', question },
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
// Seat the pane once per question. Opened unasked it sits from 144 columns and
|
|
127
|
+
// waits below that; say so, and report it so the server never counts an
|
|
128
|
+
// invisible question as shown.
|
|
129
|
+
if (openedFor !== question.question_id) {
|
|
130
|
+
openedFor = question.question_id
|
|
131
|
+
const opened = await $.ui.open({ id: PANE, title: STR.brand, closeOnEscape: true })
|
|
132
|
+
await call($, 'panel_sync', {
|
|
133
|
+
...where,
|
|
134
|
+
placed: { question_id: question.question_id, ok: opened.isPlaced, ...(opened.isPlaced ? {} : { reason: opened.reason }) },
|
|
135
|
+
})
|
|
136
|
+
if (!opened.isPlaced) $.ui.toast(unplacedNotice())
|
|
137
|
+
}
|
|
138
|
+
return 'question'
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A throttled sync for busy events: the heartbeat, and the way a question presented elsewhere finds its pane. */
|
|
142
|
+
async function beat($: EngineInterface): Promise<void> {
|
|
143
|
+
if (Date.now() - lastSyncAt < HEARTBEAT_MS) return
|
|
144
|
+
await sync($)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Says what went wrong, in words, and keeps whatever the learner had written. */
|
|
148
|
+
async function fail($: EngineInterface, message: string): Promise<void> {
|
|
149
|
+
await update($, quiz, (s: PanelState) => ({ ...s, step: 'error', message }))
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Sends one answer, grades a typed one first, and shows the verdict. Single
|
|
154
|
+
* flight: `busy` is set before anything is awaited, so a double press or a
|
|
155
|
+
* second Enter cannot send twice (and the server would answer both with the
|
|
156
|
+
* same stored reply anyway).
|
|
157
|
+
*/
|
|
158
|
+
async function submit($: EngineInterface, wanted: PanelPending | null): Promise<void> {
|
|
159
|
+
if (busy) return
|
|
160
|
+
busy = true
|
|
161
|
+
const mine = epoch
|
|
162
|
+
try {
|
|
163
|
+
const state: PanelState = (await $.state.get(REF)).value ?? EMPTY
|
|
164
|
+
const q = state.question
|
|
165
|
+
if (!q || (state.step !== 'awaiting' && state.step !== 'error')) return
|
|
166
|
+
|
|
167
|
+
let pending = wanted ?? state.pending
|
|
168
|
+
if (!pending) {
|
|
169
|
+
if (state.draft.other) {
|
|
170
|
+
if (!state.draft.text.trim()) {
|
|
171
|
+
await update($, quiz, (s: PanelState) => ({ ...s, message: STR.nothingTyped }))
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
} else if (!state.draft.picked) {
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
await update($, quiz, (s: PanelState) => ({ ...s, step: 'grading', message: null }))
|
|
180
|
+
const where = await whereAmI($)
|
|
181
|
+
if (epoch !== mine) return
|
|
182
|
+
// The session's id now, not the one the question came from: after a /clear
|
|
183
|
+
// the server then refuses this answer as another session's.
|
|
184
|
+
const base = { question_id: q.question_id, session_id: where.session_id, repo: q.repo, cwd: where.cwd }
|
|
185
|
+
|
|
186
|
+
if (!pending && state.draft.other) {
|
|
187
|
+
const typed = state.draft.text.trim()
|
|
188
|
+
const held = await call($, 'panel_answer', { ...base, kind: 'text', text: typed })
|
|
189
|
+
if (epoch !== mine) return
|
|
190
|
+
if (held.error === 'stale_question') return stale($)
|
|
191
|
+
if (typeof held.correct_label !== 'string') return fail($, STR.unreachable)
|
|
192
|
+
const asked = gradingRequest(q, held.correct_label, typed)
|
|
193
|
+
const reply = await $.model.complete(asked)
|
|
194
|
+
// The session ended while the grader ran: record nothing for it.
|
|
195
|
+
if (epoch !== mine) return
|
|
196
|
+
const verdict = reply.isAnswered ? parseVerdict(reply.text) : null
|
|
197
|
+
// A refusal, an empty reply, a timeout or anything that is not a verdict
|
|
198
|
+
// records nothing: the learner keeps their words and may try once more.
|
|
199
|
+
if (!verdict) return fail($, STR.graderFailed)
|
|
200
|
+
pending = { kind: 'text', text: typed, ...verdict }
|
|
201
|
+
} else if (!pending) {
|
|
202
|
+
pending = { kind: 'choice', option_id: state.draft.picked! }
|
|
203
|
+
}
|
|
204
|
+
const sent = pending
|
|
205
|
+
await update($, quiz, (s: PanelState) => ({ ...s, pending: sent }))
|
|
206
|
+
|
|
207
|
+
const out = await call($, 'panel_answer', { ...base, ...sent })
|
|
208
|
+
if (epoch !== mine) return
|
|
209
|
+
if (out.error === 'stale_question') return stale($)
|
|
210
|
+
if (out.error) return fail($, STR.unreachable)
|
|
211
|
+
|
|
212
|
+
await update($, quiz, (s: PanelState) => ({
|
|
213
|
+
...s,
|
|
214
|
+
step: out.phase === 'skipped' ? 'skipped' : 'feedback',
|
|
215
|
+
result: out as PanelState['result'],
|
|
216
|
+
pending: null,
|
|
217
|
+
message: null,
|
|
218
|
+
}))
|
|
219
|
+
|
|
220
|
+
// A miss with explain_on_wrong on: the explainer is started by a queued
|
|
221
|
+
// prompt, so the work is not paused and nothing here waits for it.
|
|
222
|
+
if (out.explain?.instruction && explainedAttempt !== out.attempt_id) {
|
|
223
|
+
explainedAttempt = out.attempt_id
|
|
224
|
+
await $.prompt.submit({ text: String(out.explain.instruction) })
|
|
225
|
+
}
|
|
226
|
+
} catch {
|
|
227
|
+
if (epoch === mine) await fail($, STR.unreachable)
|
|
228
|
+
} finally {
|
|
229
|
+
// Only this submission's own flag: a newer session's may be set by now.
|
|
230
|
+
if (epoch === mine) busy = false
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The question belongs to another session or project: say so, drop the draft, and look again. */
|
|
235
|
+
async function stale($: EngineInterface): Promise<void> {
|
|
236
|
+
openedFor = null
|
|
237
|
+
await update($, quiz, () => ({ ...EMPTY, message: STR.stale }))
|
|
238
|
+
await sync($)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export const register: Register = on => {
|
|
242
|
+
on('session.start', async ($, e, next) => {
|
|
243
|
+
try {
|
|
244
|
+
await $.command.register({ name: 'eklavya-panel', description: 'Open the Eklavya quiz panel' })
|
|
245
|
+
} catch {
|
|
246
|
+
/* Without the command the pane still opens by itself; only reopening is lost. */
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
await sync($)
|
|
250
|
+
} catch {
|
|
251
|
+
/* Fail open: the question stays in the database for the next sync. */
|
|
252
|
+
}
|
|
253
|
+
return next(e)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
// A /clear, a resume or a close of the session ends this session's panel
|
|
257
|
+
// state; a question still waiting in the database survives and comes back on
|
|
258
|
+
// the next sync.
|
|
259
|
+
on('session.end', async ($, e, next) => {
|
|
260
|
+
try {
|
|
261
|
+
epoch += 1
|
|
262
|
+
openedFor = null
|
|
263
|
+
busy = false
|
|
264
|
+
await update($, quiz, () => EMPTY)
|
|
265
|
+
} catch {
|
|
266
|
+
/* Nothing to undo. */
|
|
267
|
+
}
|
|
268
|
+
return next(e)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
// The question reaches the pane when the model's present_question call returns
|
|
272
|
+
// (the tool is Eklavya's, and the model carries on regardless). Any other call
|
|
273
|
+
// refreshes the heartbeat first, now and then, so it is fresh when the hooks
|
|
274
|
+
// that choose how to ask run after the tool. Eklavya's own panel_* calls pass
|
|
275
|
+
// through.
|
|
276
|
+
on('tool.call', async ($, e, next) => {
|
|
277
|
+
const name = String(e.tool)
|
|
278
|
+
if (name.includes('__panel_')) return next(e)
|
|
279
|
+
const isPresent = /^mcp__(plugin_eklavya_)?eklavya__present_question$/.test(name)
|
|
280
|
+
if (!isPresent) {
|
|
281
|
+
try {
|
|
282
|
+
await beat($)
|
|
283
|
+
} catch {
|
|
284
|
+
/* Fail open. */
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const ran = await next(e)
|
|
288
|
+
if (isPresent) {
|
|
289
|
+
try {
|
|
290
|
+
await sync($)
|
|
291
|
+
} catch {
|
|
292
|
+
/* Fail open. */
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return ran
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
on('prompt.submit', async ($, e, next) => {
|
|
299
|
+
try {
|
|
300
|
+
await beat($)
|
|
301
|
+
} catch {
|
|
302
|
+
/* Fail open. */
|
|
303
|
+
}
|
|
304
|
+
return next(e)
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
on('command.run', { command: 'eklavya-panel' }, async $ => {
|
|
308
|
+
try {
|
|
309
|
+
openedFor = null
|
|
310
|
+
// The person typed this, so the pane is seated at any width. Off is inert:
|
|
311
|
+
// the server says so and nothing is opened.
|
|
312
|
+
const found = await sync($)
|
|
313
|
+
if (found === 'disabled') return { text: STR.off }
|
|
314
|
+
if (found === 'none') await $.ui.open({ id: PANE, title: STR.brand, closeOnEscape: true })
|
|
315
|
+
return { text: 'Eklavya quiz panel opened.' }
|
|
316
|
+
} catch {
|
|
317
|
+
return { text: `The Eklavya quiz panel could not open. Try ${REOPEN} again.` }
|
|
318
|
+
}
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
on('ui.render', { component: 'Pane', requestId: 'eklavya-quiz' }, async ($, e, next) => {
|
|
322
|
+
// Only our pane: every other pane, and everything else, is the host's.
|
|
323
|
+
if (e.requestId !== PANE) return next(e)
|
|
324
|
+
const { Box, Text, Button, Input } = $.ui.resolve(e) as any
|
|
325
|
+
const s: PanelState = await read($, quiz)
|
|
326
|
+
const q = s.question
|
|
327
|
+
|
|
328
|
+
const head = (
|
|
329
|
+
<Box flexDirection="row" justifyContent="space-between">
|
|
330
|
+
<Text bold color="suggestion">
|
|
331
|
+
{STR.brand}
|
|
332
|
+
</Text>
|
|
333
|
+
<Text dimColor>{STR.powered}</Text>
|
|
334
|
+
</Box>
|
|
335
|
+
)
|
|
336
|
+
const hint = <Text dimColor>Ctrl+X then Tab moves focus here. Enter presses a button. Esc returns to the prompt.</Text>
|
|
337
|
+
const frame = (...body: unknown[]) => (
|
|
338
|
+
<Box flexDirection="column" paddingX={1} gap={1}>
|
|
339
|
+
{head}
|
|
340
|
+
{body}
|
|
341
|
+
{hint}
|
|
342
|
+
</Box>
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
if (s.step === 'loading') return frame(<Text dimColor>{STR.loading}</Text>)
|
|
346
|
+
|
|
347
|
+
if (s.step === 'none' || !q) {
|
|
348
|
+
return frame(
|
|
349
|
+
<Text dimColor>{s.message ?? STR.empty}</Text>,
|
|
350
|
+
<Box flexDirection="row" gap={2}>
|
|
351
|
+
<Button label={STR.close} role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
|
|
352
|
+
</Box>,
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const topic = <Text dimColor>{topicLabel(q)}</Text>
|
|
357
|
+
const stem = <Text bold>{q.stem}</Text>
|
|
358
|
+
|
|
359
|
+
if (s.step === 'feedback' || s.step === 'skipped') {
|
|
360
|
+
const r = s.result
|
|
361
|
+
if (s.step === 'skipped' || !r) {
|
|
362
|
+
return frame(topic, <Text>{STR.skipped}</Text>, <Button key="done" label={STR.done} variant="primary" hotkey="d" onPress={() => done($)} />)
|
|
363
|
+
}
|
|
364
|
+
const right = r.correct === true
|
|
365
|
+
return frame(
|
|
366
|
+
topic,
|
|
367
|
+
stem,
|
|
368
|
+
<Text bold color={right ? 'suggestion' : 'error'}>
|
|
369
|
+
{right ? STR.correct : STR.wrong}
|
|
370
|
+
</Text>,
|
|
371
|
+
right || !r.correct_label ? null : <Text>{answerWas(r.correct_label)}</Text>,
|
|
372
|
+
r.explanation ? <Text>{r.explanation}</Text> : null,
|
|
373
|
+
r.level_up ? <Text color="suggestion">{levelLine(r.level_up.from, projectName(q.repo))}</Text> : null,
|
|
374
|
+
r.explain ? <Text dimColor>{STR.explainer}</Text> : null,
|
|
375
|
+
<Box flexDirection="row" gap={2}>
|
|
376
|
+
{q.more ? <Button key="next" label={STR.next} hotkey="n" onPress={() => askNext($)} /> : null}
|
|
377
|
+
<Button key="done" label={STR.done} variant="primary" hotkey="d" onPress={() => done($)} />
|
|
378
|
+
</Box>,
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const locked = s.step === 'grading'
|
|
383
|
+
// Submit looks disabled until there is something to send; pressing it then does nothing.
|
|
384
|
+
const hasDraft = s.draft.other ? s.draft.text.trim().length > 0 : s.draft.picked !== null
|
|
385
|
+
const options = q.options.map((o, i) => {
|
|
386
|
+
const chosen = !s.draft.other && s.draft.picked === o.id
|
|
387
|
+
return (
|
|
388
|
+
<Box key={`row-${o.id}`} flexDirection="column">
|
|
389
|
+
<Button
|
|
390
|
+
key={`opt-${o.id}`}
|
|
391
|
+
plain
|
|
392
|
+
label={`${chosen ? '●' : '○'} ${o.label}`}
|
|
393
|
+
hotkey={String(i + 1)}
|
|
394
|
+
onPress={() => (locked ? undefined : pick($, o.id))}
|
|
395
|
+
/>
|
|
396
|
+
{o.note ? <Text dimColor>{` ${o.note}`}</Text> : null}
|
|
397
|
+
</Box>
|
|
398
|
+
)
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
return frame(
|
|
402
|
+
topic,
|
|
403
|
+
stem,
|
|
404
|
+
<Box flexDirection="column" gap={1}>
|
|
405
|
+
{options}
|
|
406
|
+
<Button
|
|
407
|
+
key="opt-other"
|
|
408
|
+
plain
|
|
409
|
+
label={`${s.draft.other ? '●' : '○'} ${STR.other}`}
|
|
410
|
+
hotkey="o"
|
|
411
|
+
onPress={() => (locked ? undefined : chooseOther($))}
|
|
412
|
+
/>
|
|
413
|
+
{s.draft.other ? (
|
|
414
|
+
<Input
|
|
415
|
+
key="other-text"
|
|
416
|
+
autoFocus
|
|
417
|
+
placeholder={STR.otherPlaceholder}
|
|
418
|
+
value={s.draft.text}
|
|
419
|
+
submitLabel="keep"
|
|
420
|
+
onInput={(value: string) => (locked ? undefined : typeOther($, value))}
|
|
421
|
+
onSubmit={(value: string) => (locked ? undefined : typeOther($, value))}
|
|
422
|
+
/>
|
|
423
|
+
) : null}
|
|
424
|
+
</Box>,
|
|
425
|
+
locked ? <Text dimColor>{STR.grading}</Text> : null,
|
|
426
|
+
s.step === 'error' && s.message ? <Text color="error">{s.message}</Text> : null,
|
|
427
|
+
s.step === 'awaiting' && s.message ? <Text color="warning">{s.message}</Text> : null,
|
|
428
|
+
<Box flexDirection="row" gap={2}>
|
|
429
|
+
{locked ? null : s.step === 'error' ? (
|
|
430
|
+
<Button key="retry" label={STR.retry} variant="primary" hotkey="r" onPress={() => submit($, null)} />
|
|
431
|
+
) : (
|
|
432
|
+
<Button key="submit" label={STR.submit} variant="primary" dimColor={!hasDraft} hotkey="s" onPress={() => submit($, null)} />
|
|
433
|
+
)}
|
|
434
|
+
{locked ? null : <Button key="skip" label={STR.skip} dimColor hotkey="k" onPress={() => skip($)} />}
|
|
435
|
+
{s.step === 'error' ? <Button key="close" label={STR.close} onPress={() => $.ui.close({ id: PANE })} /> : null}
|
|
436
|
+
</Box>,
|
|
437
|
+
)
|
|
438
|
+
})
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Selecting an option records a draft and nothing else: it never submits, grades or skips. */
|
|
442
|
+
async function pick($: EngineInterface, id: string): Promise<void> {
|
|
443
|
+
await update($, quiz, (s: PanelState) => (s.step === 'awaiting' || s.step === 'error' ? { ...s, draft: { ...s.draft, picked: id, other: false }, message: null } : s))
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function chooseOther($: EngineInterface): Promise<void> {
|
|
447
|
+
await update($, quiz, (s: PanelState) => (s.step === 'awaiting' || s.step === 'error' ? { ...s, draft: { ...s.draft, picked: null, other: true }, message: null } : s))
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function typeOther($: EngineInterface, value: string): Promise<void> {
|
|
451
|
+
await update($, quiz, (s: PanelState) => (s.step === 'awaiting' || s.step === 'error' ? { ...s, draft: { ...s.draft, text: value } } : s))
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function skip($: EngineInterface): Promise<void> {
|
|
455
|
+
await submit($, { kind: 'skip' })
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Done closes the pane; another question may already be waiting, and then the pane comes straight back. */
|
|
459
|
+
async function done($: EngineInterface): Promise<void> {
|
|
460
|
+
try {
|
|
461
|
+
openedFor = null
|
|
462
|
+
await update($, quiz, () => EMPTY)
|
|
463
|
+
await $.ui.close({ id: PANE })
|
|
464
|
+
await sync($)
|
|
465
|
+
} catch {
|
|
466
|
+
/* Fail open. */
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Next asks the model, by a queued prompt, for the next question of an explicit round. */
|
|
471
|
+
async function askNext($: EngineInterface): Promise<void> {
|
|
472
|
+
try {
|
|
473
|
+
openedFor = null
|
|
474
|
+
await update($, quiz, () => ({ ...EMPTY, step: 'loading' }))
|
|
475
|
+
await $.prompt.submit({ text: NEXT_PROMPT })
|
|
476
|
+
} catch {
|
|
477
|
+
await fail($, STR.unreachable)
|
|
478
|
+
}
|
|
479
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// The quiz panel's state contract: what the mod keeps in `$.state`.
|
|
2
|
+
//
|
|
3
|
+
// The pending question itself lives in the Eklavya database (`panel_questions`);
|
|
4
|
+
// this is only what the pane shows right now and the learner's unsent draft.
|
|
5
|
+
// A mod loses its own variables on reload, and the host keeps these.
|
|
6
|
+
|
|
7
|
+
/** What the pane needs to draw a waiting question. Never the key or the grades. */
|
|
8
|
+
export type PanelQuestion = {
|
|
9
|
+
question_id: string
|
|
10
|
+
/** Opaque: echoed back to the server so an answer is checked against its project. */
|
|
11
|
+
repo: string
|
|
12
|
+
stem: string
|
|
13
|
+
options: { id: string; label: string; note: string }[]
|
|
14
|
+
concept_name: string
|
|
15
|
+
tier: number
|
|
16
|
+
/** More questions of this round follow: the pane offers Next. */
|
|
17
|
+
more: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The learner's unsent answer. Selecting writes here and never submits. */
|
|
21
|
+
export type PanelDraft = {
|
|
22
|
+
picked: string | null
|
|
23
|
+
/** "Other" chosen: the typed text is the answer. */
|
|
24
|
+
other: boolean
|
|
25
|
+
text: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** What `panel_answer` is sent, kept so a Retry resends exactly it and a typed answer is not graded twice. */
|
|
29
|
+
export type PanelPending =
|
|
30
|
+
| { kind: 'choice'; option_id: string }
|
|
31
|
+
| { kind: 'skip' }
|
|
32
|
+
| { kind: 'text'; text: string; grade: number; outcome: 'answered' | 'dont_know'; feedback: string }
|
|
33
|
+
|
|
34
|
+
/** The reply to an answer, as `panel_answer` stored it. */
|
|
35
|
+
export type PanelResult = {
|
|
36
|
+
phase: 'answered' | 'skipped'
|
|
37
|
+
attempt_id: number
|
|
38
|
+
correct?: boolean
|
|
39
|
+
correct_label?: string
|
|
40
|
+
explanation?: string
|
|
41
|
+
level_up?: { from: string; to: string }
|
|
42
|
+
explain?: { instruction: string; attempt_id: number }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type PanelStep = 'none' | 'loading' | 'awaiting' | 'grading' | 'feedback' | 'skipped' | 'error'
|
|
46
|
+
|
|
47
|
+
export type PanelState = {
|
|
48
|
+
step: PanelStep
|
|
49
|
+
question: PanelQuestion | null
|
|
50
|
+
draft: PanelDraft
|
|
51
|
+
pending: PanelPending | null
|
|
52
|
+
result: PanelResult | null
|
|
53
|
+
/** Words for the learner: why the pane is in `error`, or a one-line notice in `none`. */
|
|
54
|
+
message: string | null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
declare module 'claude-code' {
|
|
58
|
+
interface PluginState {
|
|
59
|
+
eklavya: { quiz: PanelState }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -62,6 +62,12 @@ context-saving design.
|
|
|
62
62
|
stable fingerprints. Removing it changes duplicate detection for history.
|
|
63
63
|
- Honor list caps and truncation. Profile `known` is strongest-first, not most
|
|
64
64
|
recent; graph results can be incomplete. Recount seed totals before quoting.
|
|
65
|
+
- `presentation` in the plan decides the tool: `"tool"` is `AskUserQuestion`
|
|
66
|
+
(follow `ask_attribution`, then `record_attempt` and the verdict); `"panel"` is
|
|
67
|
+
`present_question` (no attribution, no `record_attempt`, no verdict, no waiting).
|
|
68
|
+
The panel instructions live in `tutor/references/panel.md`, not in
|
|
69
|
+
`writing-mcq.md`, so a card question never reads them. The tutor subagent has no
|
|
70
|
+
`present_question` and ignores `"panel"`.
|
|
65
71
|
- Keep `declined` distinct from `dont_know`, MCQ grading capped as the runtime
|
|
66
72
|
requires, and one-question/resume behavior intact.
|
|
67
73
|
|