@jiy/quizme 0.1.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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +48 -0
  3. package/dist/cli/config.js +37 -0
  4. package/dist/cli/index.js +64 -0
  5. package/dist/cli/session.js +10 -0
  6. package/dist/generation/dedupe.js +11 -0
  7. package/dist/generation/schema.js +50 -0
  8. package/dist/generation/validator.js +134 -0
  9. package/dist/platform/fs.js +17 -0
  10. package/dist/platform/paths.js +27 -0
  11. package/dist/providers/claudeAgent.js +295 -0
  12. package/dist/sources/claudeSession.js +112 -0
  13. package/dist/sources/repository.js +43 -0
  14. package/dist/sources/topic.js +7 -0
  15. package/dist/storage/index.js +18 -0
  16. package/dist/storage/sqlite.js +294 -0
  17. package/dist/types.js +1 -0
  18. package/dist/ui/App.js +90 -0
  19. package/dist/ui/components/AppHeader.js +6 -0
  20. package/dist/ui/components/Clawd.js +37 -0
  21. package/dist/ui/components/Divider.js +6 -0
  22. package/dist/ui/components/Feed.js +32 -0
  23. package/dist/ui/components/FeedColumn.js +9 -0
  24. package/dist/ui/components/SelectList.js +13 -0
  25. package/dist/ui/components/StatusBar.js +9 -0
  26. package/dist/ui/components/TextInput.js +23 -0
  27. package/dist/ui/components/WelcomeBanner.js +79 -0
  28. package/dist/ui/formatters.js +66 -0
  29. package/dist/ui/logoLayout.js +30 -0
  30. package/dist/ui/renderApp.js +25 -0
  31. package/dist/ui/screens/HomeScreen.js +57 -0
  32. package/dist/ui/screens/InfoScreen.js +16 -0
  33. package/dist/ui/screens/QuizScreen.js +344 -0
  34. package/dist/ui/screens/SettingsScreen.js +133 -0
  35. package/dist/ui/screens/SetupScreen.js +73 -0
  36. package/dist/ui/sound.js +101 -0
  37. package/dist/ui/terminal.js +37 -0
  38. package/dist/ui/textUtils.js +54 -0
  39. package/dist/ui/theme.js +28 -0
  40. package/dist/version.js +3 -0
  41. package/package.json +55 -0
@@ -0,0 +1,295 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import crypto from "node:crypto";
3
+ import { QUESTION_SCHEMA } from "../generation/schema.js";
4
+ import { validateQuestions } from "../generation/validator.js";
5
+ function isObject(value) {
6
+ return value !== null && typeof value === "object";
7
+ }
8
+ function isAssistantEvent(event) {
9
+ return isObject(event) && event.type === "assistant";
10
+ }
11
+ function isResultEvent(event) {
12
+ return isObject(event) && event.type === "result";
13
+ }
14
+ function getAssistantBlocks(event) {
15
+ const content = event.message?.content;
16
+ return Array.isArray(content) ? content : [];
17
+ }
18
+ function extractQuestionsPayload(value) {
19
+ if (!isObject(value)) {
20
+ return null;
21
+ }
22
+ if (!("questions" in value)) {
23
+ return null;
24
+ }
25
+ return Array.isArray(value.questions) ? value : null;
26
+ }
27
+ function summarizeSignals(signals) {
28
+ if (!signals.length)
29
+ return "None yet.";
30
+ const strongest = [...signals].sort((a, b) => b.score - a.score).slice(0, 5);
31
+ const weakest = [...signals]
32
+ .filter((s) => s.wrongCount > 0)
33
+ .sort((a, b) => a.score - b.score || b.wrongCount - a.wrongCount)
34
+ .slice(0, 5);
35
+ const format = (s) => `${s.tag}(score=${s.score.toFixed(2)}, conf=${s.confidence.toFixed(2)}, +${s.correctCount}/-${s.wrongCount})`;
36
+ return [
37
+ `Strong: ${strongest.map(format).join(", ") || "none"}`,
38
+ `Weak: ${weakest.map(format).join(", ") || "none"}`
39
+ ].join("\n");
40
+ }
41
+ function summarizePreferences(prefs) {
42
+ if (!prefs.length)
43
+ return "None.";
44
+ const boost = prefs.filter((p) => p.kind === "boost").map((p) => p.tag);
45
+ const suppress = prefs.filter((p) => p.kind === "suppress").map((p) => p.tag);
46
+ const known = prefs.filter((p) => p.kind === "known").map((p) => p.tag);
47
+ const lines = [];
48
+ if (boost.length)
49
+ lines.push(`Boost (user wants more): ${boost.join(", ")}`);
50
+ if (suppress.length)
51
+ lines.push(`Suppress (user wants less): ${suppress.join(", ")}`);
52
+ if (known.length)
53
+ lines.push(`Already known (deprioritize as strong-area): ${known.join(", ")}`);
54
+ return lines.join("\n") || "None.";
55
+ }
56
+ function buildQuizPrompt({ source, config, recentQuestions, mode, signals, preferences }) {
57
+ return [
58
+ "You are QuizMe, a CLI technical interview quiz generator for developers.",
59
+ "Return strict JSON only, matching the provided schema.",
60
+ "Generate 3 to 5 multiple-choice questions with exactly 4 choices (ids A, B, C, D) and exactly one best answer.",
61
+ "Every question MUST include a `sourceMode` field, one of: contextual (about the session/repo), adjacent (related tools/frameworks/concepts), interview_style (evergreen interview knowledge).",
62
+ "Target mix across the batch: ~40% contextual, ~40% adjacent, ~20% interview_style.",
63
+ "Every question MUST include a `whyWrong` object with a short reason for each non-answer choice id.",
64
+ "Focus on engineering judgment, debugging, tradeoffs, and code review reasoning — not trivia.",
65
+ "Weight the batch toward the user's weak areas from profile signals below; keep a small share of strong-area questions for positive reinforcement.",
66
+ "Respect user preferences: boost tags should appear more often; suppress and 'known' tags should appear less often (do not fully drop them, just deprioritize).",
67
+ `User level: ${config.level}`,
68
+ `Language for questions and explanations: ${config.language}`,
69
+ `Mode: ${mode}`,
70
+ "Profile signals:",
71
+ summarizeSignals(signals),
72
+ "Profile preferences:",
73
+ summarizePreferences(preferences),
74
+ "Recent questions to avoid repeating (topic:question pairs):",
75
+ JSON.stringify(recentQuestions.slice(0, 20).map((q) => ({ topic: q.topic, question: q.question }))),
76
+ "Source context summary:",
77
+ source.summary
78
+ ].join("\n\n");
79
+ }
80
+ function buildWhyPrompt({ question, config, asked, userAnswer }) {
81
+ return [
82
+ "You are QuizMe in why mode — an expert technical tutor.",
83
+ `Language: ${config.language}`,
84
+ `User level: ${config.level}`,
85
+ `Question: ${question.question}`,
86
+ `Choices: ${JSON.stringify(question.choices)}`,
87
+ `Correct answer: ${question.answer}`,
88
+ `User selected: ${userAnswer}`,
89
+ `Initial explanation: ${question.explanation}`,
90
+ `User follow-up question: ${asked}`,
91
+ "Provide a concise, concrete explanation. Explain why the correct answer is right, why each wrong option is weaker, and connect the concept to practical engineering work. Stay focused on this question — do not become a general tutor."
92
+ ].join("\n\n");
93
+ }
94
+ /**
95
+ * Print-mode hardening for scripted calls.
96
+ * - `--bare`: skip hooks, MCP, CLAUDE.md auto-discovery, etc.
97
+ * - `--tools ""`: disable built-in agent tools (Read/Bash/Edit/...).
98
+ * Context is already embedded in the prompt; `--json-schema` structured output
99
+ * is separate from the built-in tool set.
100
+ */
101
+ const CLAUDE_PRINT_SECURITY_ARGS = ["--bare", "--tools", ""];
102
+ /**
103
+ * Whether `claude` has already been verified on PATH this process. The check
104
+ * is cheap but synchronous, so we avoid repeating it on every generation call.
105
+ */
106
+ let claudeAvailableVerified = false;
107
+ /**
108
+ * Pre-flight check: ensure the `claude` CLI is installed and on PATH before
109
+ * we spawn a long-running print-mode call. Throws a clear, actionable error
110
+ * instead of letting `spawn` fail later with a generic ENOENT.
111
+ */
112
+ function ensureClaudeAvailable() {
113
+ if (claudeAvailableVerified)
114
+ return;
115
+ const result = spawnSync("claude", ["--version"], {
116
+ stdio: "ignore",
117
+ shell: process.platform === "win32"
118
+ });
119
+ if (result.error || result.status !== 0) {
120
+ throw new Error([
121
+ "Claude Code CLI (`claude`) was not found on your PATH.",
122
+ "QuizMe needs it for quiz generation and the `why` mode.",
123
+ "",
124
+ "Install it with: npm install -g @anthropic-ai/claude-code",
125
+ "Docs: https://docs.anthropic.com/claude-code",
126
+ "",
127
+ "Or run QuizMe offline with QUIZME_PROVIDER=local."
128
+ ].join("\n"));
129
+ }
130
+ claudeAvailableVerified = true;
131
+ }
132
+ async function runClaude(args, { onEvent, timeout = 120000 } = {}) {
133
+ return new Promise((resolve, reject) => {
134
+ const child = spawn("claude", [...CLAUDE_PRINT_SECURITY_ARGS, ...args], {
135
+ stdio: ["ignore", "pipe", "pipe"]
136
+ });
137
+ const chunks = [];
138
+ let stderr = "";
139
+ let timedOut = false;
140
+ const timer = setTimeout(() => {
141
+ timedOut = true;
142
+ child.kill("SIGTERM");
143
+ reject(new Error("Claude timed out after " + timeout / 1000 + "s"));
144
+ }, timeout);
145
+ let buffer = "";
146
+ child.stdout.on("data", (chunk) => {
147
+ buffer += chunk.toString();
148
+ chunks.push(chunk.toString());
149
+ // Parse complete newline-delimited JSON events
150
+ const lines = buffer.split("\n");
151
+ buffer = lines.pop() ?? ""; // keep incomplete line in buffer
152
+ for (const line of lines) {
153
+ const trimmed = line.trim();
154
+ if (!trimmed)
155
+ continue;
156
+ if (onEvent) {
157
+ try {
158
+ const event = JSON.parse(trimmed);
159
+ onEvent(event);
160
+ }
161
+ catch {
162
+ // not a JSON event line, ignore
163
+ }
164
+ }
165
+ }
166
+ });
167
+ child.stderr.on("data", (chunk) => {
168
+ stderr += chunk.toString();
169
+ });
170
+ child.on("close", (code) => {
171
+ clearTimeout(timer);
172
+ if (timedOut) {
173
+ return;
174
+ }
175
+ if (code !== 0) {
176
+ const msg = stderr.trim() || `claude exited with code ${code}`;
177
+ reject(new Error(msg));
178
+ return;
179
+ }
180
+ resolve(chunks.join(""));
181
+ });
182
+ child.on("error", (err) => {
183
+ clearTimeout(timer);
184
+ reject(new Error(`Failed to spawn claude: ${err.message}. Is Claude Code installed and in PATH?`));
185
+ });
186
+ });
187
+ }
188
+ function parseQuestionsFromEvents(events) {
189
+ for (const event of events) {
190
+ if (isAssistantEvent(event)) {
191
+ for (const block of getAssistantBlocks(event)) {
192
+ // StructuredOutput tool_use contains the JSON schema result
193
+ if (isObject(block) && block.type === "tool_use" && block.name === "StructuredOutput" && block.input) {
194
+ const payload = extractQuestionsPayload(block.input);
195
+ if (payload) {
196
+ return payload;
197
+ }
198
+ }
199
+ // Fallback: text block containing JSON
200
+ if (isObject(block) && block.type === "text" && typeof block.text === "string") {
201
+ try {
202
+ const parsed = JSON.parse(block.text);
203
+ const payload = extractQuestionsPayload(parsed);
204
+ if (payload)
205
+ return payload;
206
+ }
207
+ catch {
208
+ // not JSON
209
+ }
210
+ }
211
+ }
212
+ }
213
+ // result event may have JSON in result field
214
+ if (isResultEvent(event) && event.result) {
215
+ try {
216
+ const parsed = JSON.parse(event.result);
217
+ const payload = extractQuestionsPayload(parsed);
218
+ if (payload)
219
+ return payload;
220
+ }
221
+ catch {
222
+ // not JSON
223
+ }
224
+ }
225
+ }
226
+ return null;
227
+ }
228
+ function extractTextFromEvents(events) {
229
+ const parts = [];
230
+ for (const event of events) {
231
+ if (isAssistantEvent(event)) {
232
+ for (const block of getAssistantBlocks(event)) {
233
+ if (isObject(block) && block.type === "text" && typeof block.text === "string") {
234
+ parts.push(block.text);
235
+ }
236
+ }
237
+ }
238
+ if (isResultEvent(event) && event.result) {
239
+ return event.result;
240
+ }
241
+ }
242
+ return parts.join("");
243
+ }
244
+ export async function generateQuestions({ source, config, recentQuestions, mode = "mixed", signals = [], preferences = [], onProgress }) {
245
+ ensureClaudeAvailable();
246
+ const prompt = buildQuizPrompt({ source, config, recentQuestions, mode, signals, preferences });
247
+ const events = [];
248
+ await runClaude([
249
+ "-p",
250
+ "--output-format", "stream-json",
251
+ "--verbose",
252
+ "--json-schema", JSON.stringify(QUESTION_SCHEMA),
253
+ prompt
254
+ ], {
255
+ timeout: 300000,
256
+ onEvent: (event) => {
257
+ events.push(event);
258
+ if (onProgress && isAssistantEvent(event)) {
259
+ onProgress(".");
260
+ }
261
+ }
262
+ });
263
+ const parsed = parseQuestionsFromEvents(events);
264
+ if (!parsed) {
265
+ throw new Error("Claude returned no questions. Try again or use a different source.");
266
+ }
267
+ const questions = validateQuestions(parsed);
268
+ return questions.map((q, index) => ({
269
+ ...q,
270
+ id: q.id || `q_${crypto.createHash("sha1").update(`${source.title}:${q.question}:${index}`).digest("hex").slice(0, 10)}`
271
+ }));
272
+ }
273
+ export async function generateWhy({ question, config, asked, userAnswer, onProgress }) {
274
+ ensureClaudeAvailable();
275
+ const prompt = buildWhyPrompt({ question, config, asked, userAnswer });
276
+ const events = [];
277
+ let streamedText = "";
278
+ await runClaude(["-p", "--output-format", "stream-json", "--verbose", prompt], {
279
+ onEvent: (event) => {
280
+ events.push(event);
281
+ if (onProgress && isAssistantEvent(event)) {
282
+ for (const block of getAssistantBlocks(event)) {
283
+ if (isObject(block) && block.type === "text" && typeof block.text === "string") {
284
+ streamedText += block.text;
285
+ onProgress(block.text);
286
+ }
287
+ }
288
+ }
289
+ }
290
+ });
291
+ if (streamedText) {
292
+ return streamedText;
293
+ }
294
+ return extractTextFromEvents(events).trim();
295
+ }
@@ -0,0 +1,112 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { getClaudeProjectsDir } from "../platform/paths.js";
4
+ function isObject(value) {
5
+ return value !== null && typeof value === "object";
6
+ }
7
+ function getMessageContent(row) {
8
+ const message = row.message;
9
+ if (!isObject(message)) {
10
+ return null;
11
+ }
12
+ return typeof message.content === "string" ? message.content : null;
13
+ }
14
+ function parseJsonLines(filePath) {
15
+ const content = fs.readFileSync(filePath, "utf8");
16
+ return content
17
+ .split("\n")
18
+ .filter(Boolean)
19
+ .map((line) => {
20
+ try {
21
+ const parsed = JSON.parse(line);
22
+ return isObject(parsed) ? parsed : null;
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ })
28
+ .filter((row) => row !== null);
29
+ }
30
+ function listSessionFiles(projectDir) {
31
+ return fs.readdirSync(projectDir)
32
+ .filter((name) => name.endsWith(".jsonl"))
33
+ .map((name) => path.join(projectDir, name))
34
+ .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
35
+ }
36
+ function summarizeRows(rows) {
37
+ const userRows = rows.filter((row) => row.type === "user" && getMessageContent(row));
38
+ const assistantRows = rows.filter((row) => row.type === "assistant" && getMessageContent(row));
39
+ const cwdRow = rows.find((row) => typeof row.cwd === "string");
40
+ const cwd = typeof cwdRow?.cwd === "string" ? cwdRow.cwd : "unknown";
41
+ return {
42
+ cwd,
43
+ userCount: userRows.length,
44
+ assistantCount: assistantRows.length,
45
+ promptPreview: userRows
46
+ .slice(-3)
47
+ .map((row) => {
48
+ const content = getMessageContent(row);
49
+ return (content ?? "").replace(/\s+/g, " ").trim();
50
+ })
51
+ .join("\n")
52
+ };
53
+ }
54
+ function findMostRecentSessionGlobally() {
55
+ const projectsDir = getClaudeProjectsDir();
56
+ if (!fs.existsSync(projectsDir)) {
57
+ return null;
58
+ }
59
+ const allFiles = fs.readdirSync(projectsDir)
60
+ .flatMap((entry) => {
61
+ const dir = path.join(projectsDir, entry);
62
+ try {
63
+ if (!fs.statSync(dir).isDirectory())
64
+ return [];
65
+ return listSessionFiles(dir);
66
+ }
67
+ catch {
68
+ return [];
69
+ }
70
+ })
71
+ .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
72
+ return allFiles.length ? allFiles[0] : null;
73
+ }
74
+ function findLatestSessionFile() {
75
+ const projectsDir = getClaudeProjectsDir();
76
+ const mostRecent = findMostRecentSessionGlobally();
77
+ if (!mostRecent) {
78
+ throw new Error(`No Claude project transcripts found in ${projectsDir}. Run Claude Code in this repo first, or use --repo / "topic".`);
79
+ }
80
+ return mostRecent;
81
+ }
82
+ export function getClaudeSummaryFromFile(filePath, cwd = process.cwd()) {
83
+ const rows = parseJsonLines(filePath);
84
+ const { promptPreview } = summarizeRows(rows);
85
+ const userMessages = rows
86
+ .filter((row) => row.type === "user" && getMessageContent(row))
87
+ .slice(-8)
88
+ .map((row) => (getMessageContent(row) ?? "").replace(/\s+/g, " ").trim())
89
+ .join("\n");
90
+ const assistantMessages = rows
91
+ .filter((row) => row.type === "assistant" && getMessageContent(row))
92
+ .slice(-4)
93
+ .map((row) => JSON.stringify(getMessageContent(row)))
94
+ .join("\n");
95
+ return {
96
+ sourceType: "claude_session",
97
+ title: path.basename(filePath),
98
+ summary: [
99
+ `Claude project path: ${cwd}`,
100
+ `Transcript file: ${path.basename(filePath)}`,
101
+ "Prompt preview:",
102
+ promptPreview || "No recent prompt preview found.",
103
+ "Recent user prompts:",
104
+ userMessages || "No recent user prompts found.",
105
+ "Recent assistant content:",
106
+ assistantMessages || "No recent assistant messages found."
107
+ ].join("\n")
108
+ };
109
+ }
110
+ export function getLatestClaudeSummary(cwd = process.cwd()) {
111
+ return getClaudeSummaryFromFile(findLatestSessionFile(), cwd);
112
+ }
@@ -0,0 +1,43 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { execFileSync } from "node:child_process";
4
+ function readIfExists(filePath, max = 4000) {
5
+ if (!fs.existsSync(filePath)) {
6
+ return "";
7
+ }
8
+ return fs.readFileSync(filePath, "utf8").slice(0, max);
9
+ }
10
+ function safeGit(args, cwd) {
11
+ try {
12
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
13
+ }
14
+ catch {
15
+ return "";
16
+ }
17
+ }
18
+ export function getRepoSummary(repoPath = process.cwd()) {
19
+ const packageJson = readIfExists(path.join(repoPath, "package.json"), 5000);
20
+ const readme = readIfExists(path.join(repoPath, "README.md"), 5000);
21
+ const srcFiles = fs.existsSync(repoPath)
22
+ ? fs.readdirSync(repoPath).slice(0, 30).join(", ")
23
+ : "";
24
+ const gitStatus = safeGit(["status", "--short"], repoPath);
25
+ const gitLog = safeGit(["log", "--oneline", "-5"], repoPath);
26
+ return {
27
+ sourceType: "repo",
28
+ title: path.basename(repoPath),
29
+ summary: [
30
+ `Repository: ${repoPath}`,
31
+ "Top-level files:",
32
+ srcFiles,
33
+ "package.json excerpt:",
34
+ packageJson || "None",
35
+ "README excerpt:",
36
+ readme || "None",
37
+ "Git status:",
38
+ gitStatus || "Unavailable",
39
+ "Recent commits:",
40
+ gitLog || "Unavailable"
41
+ ].join("\n")
42
+ };
43
+ }
@@ -0,0 +1,7 @@
1
+ export function getTopicSummary(topic) {
2
+ return {
3
+ sourceType: "topic",
4
+ title: topic,
5
+ summary: `Topic requested by user: ${topic}`
6
+ };
7
+ }
@@ -0,0 +1,18 @@
1
+ import path from "node:path";
2
+ import { getAppDataDir } from "../platform/paths.js";
3
+ import { SqliteStore } from "./sqlite.js";
4
+ import { ensureDir } from "../platform/fs.js";
5
+ export function createStore() {
6
+ let dataDir = getAppDataDir();
7
+ try {
8
+ ensureDir(dataDir);
9
+ }
10
+ catch {
11
+ dataDir = path.join(process.cwd(), ".quizme");
12
+ ensureDir(dataDir);
13
+ }
14
+ const dbPath = path.join(dataDir, "history.sqlite");
15
+ const store = new SqliteStore(dbPath);
16
+ store.init();
17
+ return store;
18
+ }