@jiy/quizme 0.1.0 → 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/README.md CHANGED
@@ -39,10 +39,37 @@ quizme --repo .
39
39
  quizme "React rendering and caching"
40
40
  ```
41
41
 
42
+ ## 配置
43
+
44
+ ### 交互式设置(持久化)
45
+
46
+ 首次运行会引导选择语言与等级;运行中进入「设置」页可调整以下项,改动写入本地配置:
47
+
48
+ | 项 | 说明 | 可选值 |
49
+ | --- | --- | --- |
50
+ | 语言 | 题目与解释语言 | 中文 / English |
51
+ | 等级 | 题目难度定位 | Junior / Mid / Senior / Staff+ |
52
+ | 每日目标 | 每日题目数 | 1–9 |
53
+ | 音效 | 答题音效开关 | 开 / 关 |
54
+ | 题目模型 | 生成题目时传给 `claude --model` 的别名 | Haiku(默认,快)/ Sonnet / Opus / 账号默认 |
55
+ | 题目 Effort | 生成题目时传给 `claude --effort` 的等级 | Low(默认)/ Medium / High / xHigh / Max |
56
+
57
+ > 默认 `Haiku` + `Low`:题目生成本质是结构化 JSON 输出,Haiku/low 足够且明显更快、更省。需要更高质量时可在设置页临时调高。
58
+
59
+ ### 环境变量
60
+
61
+ | 变量 | 作用 |
62
+ | --- | --- |
63
+ | `QUIZME_CLAUDE_BIN` | 指定 `claude` 可执行文件的绝对路径(PATH 找不到时使用) |
64
+ | `QUIZME_DATA_DIR` | 覆盖本地数据存储目录;不设时使用平台 app data 目录,受限环境 fallback 到 `./.quizme` |
65
+ | `QUIZME_CLAUDE_WHY_MODEL` | `why` 模式(答错后深度讲解)使用的模型别名;不设则走账号默认模型 |
66
+ | `QUIZME_CLAUDE_WHY_EFFORT` | `why` 模式的 effort 等级(`low`/`medium`/`high`/`xhigh`/`max`);不设则走默认 |
67
+
68
+ > `why` 模式刻意与题目生成分开配置:讲解对模型质量更敏感,默认保持账号模型;仅当想全局降级/提速时才设这两个变量。
69
+
42
70
  ## 说明
43
71
 
44
72
  - 默认模式会从 `~/.claude/projects` 读取当前仓库最近的 Claude Code transcript。统计、档案、设置、复习等功能通过交互式主界面进入。
45
73
  - 本地数据存储在平台 app data 目录中,并使用 `sqlite3`。
46
- - 在受限环境中,存储会 fallback 到 `./.quizme`。也可以通过 `QUIZME_DATA_DIR=/path/to/data` 覆盖数据目录。
47
74
  - 题目生成和 `why` 模式会调用本地 `claude` CLI 的 print mode(`--bare` + `--tools ""`,禁用 agent tool;上下文已写入 prompt)。
48
- - 离线 demo 可使用 `QUIZME_PROVIDER=local`。如果希望优先使用 Claude、失败后 fallback 到本地 provider,可使用 `QUIZME_PROVIDER_FALLBACK=local`。
75
+ - 离线 provider(`QUIZME_PROVIDER=local`、`QUIZME_PROVIDER_FALLBACK=local`)为**暂未实现**的能力,当前不可用。
@@ -1,4 +1,16 @@
1
1
  import { runInkSetup } from "../ui/renderApp.js";
2
+ const DEFAULT_CLAUDE_MODEL = "haiku";
3
+ const DEFAULT_CLAUDE_EFFORT = "low";
4
+ function normalizeEffort(value) {
5
+ if (value === "low" ||
6
+ value === "medium" ||
7
+ value === "high" ||
8
+ value === "xhigh" ||
9
+ value === "max") {
10
+ return value;
11
+ }
12
+ return undefined;
13
+ }
2
14
  export async function ensureConfig(store) {
3
15
  const existing = store.getConfig("user");
4
16
  if (existing) {
@@ -12,12 +24,18 @@ export async function ensureConfig(store) {
12
24
  return normalizeConfig(config);
13
25
  }
14
26
  export function normalizeConfig(config = {}) {
27
+ const claudeModel = typeof config.claudeModel === "string" && config.claudeModel.trim()
28
+ ? config.claudeModel.trim()
29
+ : DEFAULT_CLAUDE_MODEL;
30
+ const claudeEffort = normalizeEffort(config.claudeEffort) ?? DEFAULT_CLAUDE_EFFORT;
15
31
  return {
16
32
  level: config.level || "mid",
17
33
  language: config.language || "en",
18
34
  dailyGoal: Number(config.dailyGoal || 5),
19
35
  soundEnabled: config.soundEnabled === true,
20
- createdAt: config.createdAt || new Date().toISOString()
36
+ createdAt: config.createdAt || new Date().toISOString(),
37
+ claudeModel,
38
+ claudeEffort
21
39
  };
22
40
  }
23
41
  function pickLevel(value) {
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Static instructional text for quiz generation.
3
+ *
4
+ * This is the part you tune when iterating on quiz quality — keep it
5
+ * decoupled from the dynamic context (signals, recent questions, source)
6
+ * assembled by {@link buildQuizPrompt} below.
7
+ */
8
+ export const QUIZ_PROMPT_INSTRUCTIONS = [
9
+ "You are QuizMe, a CLI technical interview quiz generator for developers.",
10
+ "Return strict JSON only, matching the provided schema.",
11
+ "Generate exactly 5 multiple-choice questions with exactly 4 choices (ids A, B, C, D) and exactly one best answer.",
12
+ "Across the 5 questions, aim for this mix: 2 contextual, 2 adjacent, 1 interview_style.",
13
+ "Every question MUST include a `whyWrong` object with a short reason for each non-answer choice id.",
14
+ "Lean toward questions a well-rounded engineer should genuinely know: underlying principles, technology selection and tradeoffs, comparative distinctions between similar tools or patterns, and debugging / code-review judgment.",
15
+ "Each question in the batch MUST probe a distinct concept — vary the topic, depth, and angle so no two questions read as restatements of each other.",
16
+ "Prefer questions with a spark of intrigue and a clear, transferable takeaway: the reader should learn something concrete, not merely be quizzed. Keep them broadly applicable across teams and codebases.",
17
+ "Avoid: vague or ambiguous premises; pure business-logic trivia tied to one app; overly niche implementation details; tedious questions; anything requiring scratch arithmetic or hand-tracing long code; and questions so hard they frustrate instead of teach.",
18
+ "Weight the batch toward the user's weak areas from profile signals below; keep a small share of strong-area questions for positive reinforcement."
19
+ ].join("\n");
20
+ /**
21
+ * Render profile signals into a compact strengths/weaknesses summary for the
22
+ * prompt. Top 5 by score and bottom 5 with wrong answers are surfaced so the
23
+ * generator can both reinforce and challenge the user.
24
+ */
25
+ function summarizeSignals(signals) {
26
+ if (!signals.length)
27
+ return "None yet.";
28
+ const strongest = [...signals].sort((a, b) => b.score - a.score).slice(0, 5);
29
+ const weakest = [...signals]
30
+ .filter((s) => s.wrongCount > 0)
31
+ .sort((a, b) => a.score - a.score || b.wrongCount - a.wrongCount)
32
+ .slice(0, 5);
33
+ const format = (s) => `${s.tag}(score=${s.score.toFixed(2)}, conf=${s.confidence.toFixed(2)}, +${s.correctCount}/-${s.wrongCount})`;
34
+ return [
35
+ `Strong: ${strongest.map(format).join(", ") || "none"}`,
36
+ `Weak: ${weakest.map(format).join(", ") || "none"}`
37
+ ].join("\n");
38
+ }
39
+ /**
40
+ * Assemble the full quiz-generation prompt: static instructions plus the
41
+ * dynamic per-call context (level, language, mode, profile signals, recent
42
+ * questions to avoid, and the source summary).
43
+ */
44
+ export function buildQuizPrompt({ source, config, recentQuestions, mode, signals }) {
45
+ return [
46
+ QUIZ_PROMPT_INSTRUCTIONS,
47
+ `User level: ${config.level}`,
48
+ `Language for questions and explanations: ${config.language}`,
49
+ `Mode: ${mode}`,
50
+ "Profile signals:",
51
+ summarizeSignals(signals),
52
+ "Recent questions to avoid repeating (topic:question pairs):",
53
+ JSON.stringify(recentQuestions.slice(0, 20).map((q) => ({ topic: q.topic, question: q.question }))),
54
+ "Source context summary:",
55
+ source.summary
56
+ ].join("\n\n");
57
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Static instructional text for why-mode (on-demand explanation).
3
+ *
4
+ * Tuned separately from quiz generation — why mode is a deep, single-question
5
+ * tutor drill-down, not a batch generator. Keep this decoupled from the
6
+ * dynamic question/answer context assembled by {@link buildWhyPrompt} below.
7
+ */
8
+ export const WHY_PROMPT_INSTRUCTIONS = [
9
+ "You are QuizMe in why mode — an expert technical tutor.",
10
+ "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.",
11
+ "Stay focused on this question — do not become a general tutor."
12
+ ].join("\n");
13
+ /**
14
+ * Assemble the full why-mode prompt: static instructions plus the dynamic
15
+ * per-call context (question, choices, correct answer, user selection, the
16
+ * question's own explanation, and the user's follow-up).
17
+ */
18
+ export function buildWhyPrompt({ question, config, asked, userAnswer }) {
19
+ return [
20
+ WHY_PROMPT_INSTRUCTIONS,
21
+ `Language: ${config.language}`,
22
+ `User level: ${config.level}`,
23
+ `Question: ${question.question}`,
24
+ `Choices: ${JSON.stringify(question.choices)}`,
25
+ `Correct answer: ${question.answer}`,
26
+ `User selected: ${userAnswer}`,
27
+ `Initial explanation: ${question.explanation}`,
28
+ `User follow-up question: ${asked}`
29
+ ].join("\n\n");
30
+ }
@@ -3,7 +3,7 @@ export const QUESTION_SCHEMA = {
3
3
  properties: {
4
4
  questions: {
5
5
  type: "array",
6
- minItems: 3,
6
+ minItems: 5,
7
7
  maxItems: 5,
8
8
  items: {
9
9
  type: "object",
@@ -1,6 +1,11 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
2
  import crypto from "node:crypto";
3
+ import { existsSync } from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
3
6
  import { QUESTION_SCHEMA } from "../generation/schema.js";
7
+ import { buildQuizPrompt } from "../generation/prompts/quiz.js";
8
+ import { buildWhyPrompt } from "../generation/prompts/why.js";
4
9
  import { validateQuestions } from "../generation/validator.js";
5
10
  function isObject(value) {
6
11
  return value !== null && typeof value === "object";
@@ -24,73 +29,6 @@ function extractQuestionsPayload(value) {
24
29
  }
25
30
  return Array.isArray(value.questions) ? value : null;
26
31
  }
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
32
  /**
95
33
  * Print-mode hardening for scripted calls.
96
34
  * - `--bare`: skip hooks, MCP, CLAUDE.md auto-discovery, etc.
@@ -100,22 +38,135 @@ function buildWhyPrompt({ question, config, asked, userAnswer }) {
100
38
  */
101
39
  const CLAUDE_PRINT_SECURITY_ARGS = ["--bare", "--tools", ""];
102
40
  /**
103
- * Whether `claude` has already been verified on PATH this process. The check
41
+ * Effort levels accepted by `claude --effort`. Anything outside this set is
42
+ * ignored so a stray config value never produces an invalid CLI flag.
43
+ */
44
+ const VALID_EFFORTS = new Set([
45
+ "low",
46
+ "medium",
47
+ "high",
48
+ "xhigh",
49
+ "max"
50
+ ]);
51
+ /**
52
+ * Build the `--model` / `--effort` flag pair for a `claude` print-mode call.
53
+ * Empty/unknown values are dropped so we never pass an invalid flag — the
54
+ * account default model/effort applies instead.
55
+ */
56
+ function buildModelArgs(model, effort) {
57
+ const args = [];
58
+ const trimmedModel = typeof model === "string" ? model.trim() : "";
59
+ if (trimmedModel) {
60
+ args.push("--model", trimmedModel);
61
+ }
62
+ if (effort && VALID_EFFORTS.has(effort)) {
63
+ args.push("--effort", effort);
64
+ }
65
+ return args;
66
+ }
67
+ /**
68
+ * Resolve why-mode model/effort from the environment. Why mode is a deep,
69
+ * on-demand explanation — it's tuned separately from quiz generation so a
70
+ * faster/cheaper quiz default doesn't force the tutor down to the same tier.
71
+ */
72
+ function buildWhyModelArgs() {
73
+ const model = process.env.QUIZME_CLAUDE_WHY_MODEL?.trim();
74
+ const effort = process.env.QUIZME_CLAUDE_WHY_EFFORT?.trim();
75
+ return buildModelArgs(model, effort);
76
+ }
77
+ /**
78
+ * Whether `claude` has already been verified this process. The check
104
79
  * is cheap but synchronous, so we avoid repeating it on every generation call.
105
80
  */
106
81
  let claudeAvailableVerified = false;
107
82
  /**
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.
83
+ * Resolved absolute path to the `claude` binary, cached after first lookup.
84
+ * `null` means "defer to PATH" (i.e. spawn `"claude"` and let the OS resolve).
85
+ * `undefined` means "not yet looked up".
111
86
  */
112
- function ensureClaudeAvailable() {
113
- if (claudeAvailableVerified)
114
- return;
115
- const result = spawnSync("claude", ["--version"], {
87
+ let claudeBinPath = undefined;
88
+ /**
89
+ * Locations we probe for the `claude` CLI when it isn't on PATH. This covers
90
+ * the common install paths that GUI launchers / IDE integrated terminals often
91
+ * strip from PATH (notably `~/.local/bin`, where the native installer drops it).
92
+ */
93
+ const CLAUDE_BIN_CANDIDATES = (() => {
94
+ const home = os.homedir();
95
+ const candidates = [
96
+ path.join(home, ".local/bin/claude"),
97
+ path.join(home, ".volta/bin/claude"),
98
+ path.join(home, ".bun/bin/claude"),
99
+ path.join(home, ".npm-global/bin/claude"),
100
+ "/usr/local/bin/claude",
101
+ "/opt/homebrew/bin/claude"
102
+ ];
103
+ // nvm: probe each installed node version's bin dir.
104
+ const nvmDir = process.env.NVM_DIR ?? path.join(home, ".nvm");
105
+ try {
106
+ const versions = existsSync(path.join(nvmDir, "versions/node"))
107
+ ? spawnSync("ls", [path.join(nvmDir, "versions/node")], { encoding: "utf8" }).stdout?.trim().split(/\s+/) ?? []
108
+ : [];
109
+ for (const v of versions) {
110
+ if (v)
111
+ candidates.push(path.join(nvmDir, "versions/node", v, "bin/claude"));
112
+ }
113
+ }
114
+ catch {
115
+ // ignore — nvm probe is best-effort
116
+ }
117
+ return candidates;
118
+ })();
119
+ /**
120
+ * Resolve the `claude` CLI binary path. Honors `QUIZME_CLAUDE_BIN` first, then
121
+ * PATH lookup, then a list of well-known install locations. Returns the
122
+ * absolute path if found, or `null` to fall back to a plain `claude` spawn.
123
+ */
124
+ function resolveClaudeBin() {
125
+ if (claudeBinPath !== undefined)
126
+ return claudeBinPath;
127
+ // 1. Explicit override.
128
+ const override = process.env.QUIZME_CLAUDE_BIN;
129
+ if (override) {
130
+ claudeBinPath = override;
131
+ return claudeBinPath;
132
+ }
133
+ // 2. On PATH.
134
+ const pathCheck = spawnSync("claude", ["--version"], {
116
135
  stdio: "ignore",
117
136
  shell: process.platform === "win32"
118
137
  });
138
+ if (!pathCheck.error && pathCheck.status === 0) {
139
+ claudeBinPath = null; // let spawn resolve via PATH
140
+ return claudeBinPath;
141
+ }
142
+ // 3. Well-known install locations.
143
+ for (const candidate of CLAUDE_BIN_CANDIDATES) {
144
+ if (existsSync(candidate)) {
145
+ const verify = spawnSync(candidate, ["--version"], { stdio: "ignore" });
146
+ if (!verify.error && verify.status === 0) {
147
+ claudeBinPath = candidate;
148
+ return claudeBinPath;
149
+ }
150
+ }
151
+ }
152
+ claudeBinPath = null;
153
+ return claudeBinPath;
154
+ }
155
+ /**
156
+ * Pre-flight check: ensure the `claude` CLI is installed before we spawn a
157
+ * long-running print-mode call. Throws a clear, actionable error instead of
158
+ * letting `spawn` fail later with a generic ENOENT.
159
+ */
160
+ function ensureClaudeAvailable() {
161
+ if (claudeAvailableVerified)
162
+ return;
163
+ const bin = resolveClaudeBin();
164
+ const result = bin
165
+ ? spawnSync(bin, ["--version"], { stdio: "ignore" })
166
+ : spawnSync("claude", ["--version"], {
167
+ stdio: "ignore",
168
+ shell: process.platform === "win32"
169
+ });
119
170
  if (result.error || result.status !== 0) {
120
171
  throw new Error([
121
172
  "Claude Code CLI (`claude`) was not found on your PATH.",
@@ -124,14 +175,20 @@ function ensureClaudeAvailable() {
124
175
  "Install it with: npm install -g @anthropic-ai/claude-code",
125
176
  "Docs: https://docs.anthropic.com/claude-code",
126
177
  "",
127
- "Or run QuizMe offline with QUIZME_PROVIDER=local."
178
+ "If it is installed but not on this process's PATH, set",
179
+ " QUIZME_CLAUDE_BIN=/absolute/path/to/claude",
180
+ "(also checked: " + CLAUDE_BIN_CANDIDATES.join(", ") + ")",
181
+ "",
182
+ "Note: an offline local provider (QUIZME_PROVIDER=local) is documented",
183
+ "but not yet implemented — the CLI still requires `claude`."
128
184
  ].join("\n"));
129
185
  }
130
186
  claudeAvailableVerified = true;
131
187
  }
132
188
  async function runClaude(args, { onEvent, timeout = 120000 } = {}) {
133
189
  return new Promise((resolve, reject) => {
134
- const child = spawn("claude", [...CLAUDE_PRINT_SECURITY_ARGS, ...args], {
190
+ const bin = resolveClaudeBin() ?? "claude";
191
+ const child = spawn(bin, [...CLAUDE_PRINT_SECURITY_ARGS, ...args], {
135
192
  stdio: ["ignore", "pipe", "pipe"]
136
193
  });
137
194
  const chunks = [];
@@ -241,11 +298,12 @@ function extractTextFromEvents(events) {
241
298
  }
242
299
  return parts.join("");
243
300
  }
244
- export async function generateQuestions({ source, config, recentQuestions, mode = "mixed", signals = [], preferences = [], onProgress }) {
301
+ export async function generateQuestions({ source, config, recentQuestions, mode = "mixed", signals = [], onProgress }) {
245
302
  ensureClaudeAvailable();
246
- const prompt = buildQuizPrompt({ source, config, recentQuestions, mode, signals, preferences });
303
+ const prompt = buildQuizPrompt({ source, config, recentQuestions, mode, signals });
247
304
  const events = [];
248
305
  await runClaude([
306
+ ...buildModelArgs(config.claudeModel, config.claudeEffort),
249
307
  "-p",
250
308
  "--output-format", "stream-json",
251
309
  "--verbose",
@@ -275,7 +333,7 @@ export async function generateWhy({ question, config, asked, userAnswer, onProgr
275
333
  const prompt = buildWhyPrompt({ question, config, asked, userAnswer });
276
334
  const events = [];
277
335
  let streamedText = "";
278
- await runClaude(["-p", "--output-format", "stream-json", "--verbose", prompt], {
336
+ await runClaude(["-p", ...buildWhyModelArgs(), "--output-format", "stream-json", "--verbose", prompt], {
279
337
  onEvent: (event) => {
280
338
  events.push(event);
281
339
  if (onProgress && isAssistantEvent(event)) {
@@ -1,6 +1,10 @@
1
+ import crypto from "node:crypto";
1
2
  import fs from "node:fs";
3
+ import os from "node:os";
2
4
  import path from "node:path";
3
- import { getClaudeProjectsDir } from "../platform/paths.js";
5
+ function getClaudeProjectsDir() {
6
+ return path.join(os.homedir(), ".claude", "projects");
7
+ }
4
8
  function isObject(value) {
5
9
  return value !== null && typeof value === "object";
6
10
  }
@@ -51,12 +55,19 @@ function summarizeRows(rows) {
51
55
  .join("\n")
52
56
  };
53
57
  }
54
- function findMostRecentSessionGlobally() {
58
+ /**
59
+ * Scan every `~/.claude/projects/<project>/*.jsonl` transcript globally and return them
60
+ * sorted by mtime (newest first). Scanning globally — rather than only the
61
+ * current project dir — is intentional: users fire QuizMe from any directory
62
+ * during Claude Code wait times and should get their latest session context
63
+ * regardless of where they launched it from.
64
+ */
65
+ function listAllSessionFiles() {
55
66
  const projectsDir = getClaudeProjectsDir();
56
67
  if (!fs.existsSync(projectsDir)) {
57
- return null;
68
+ return [];
58
69
  }
59
- const allFiles = fs.readdirSync(projectsDir)
70
+ return fs.readdirSync(projectsDir)
60
71
  .flatMap((entry) => {
61
72
  const dir = path.join(projectsDir, entry);
62
73
  try {
@@ -69,15 +80,26 @@ function findMostRecentSessionGlobally() {
69
80
  }
70
81
  })
71
82
  .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
72
- return allFiles.length ? allFiles[0] : null;
73
83
  }
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".`);
84
+ /**
85
+ * Pick `count` items from `items` uniformly at random without replacement
86
+ * (Fisher–Yates partial shuffle). Returns at most `min(count, items.length)`
87
+ * items, preserving the original (newest-first) order for readability.
88
+ */
89
+ function pickRandom(items, count) {
90
+ if (items.length === 0 || count <= 0)
91
+ return [];
92
+ const k = Math.min(count, items.length);
93
+ const indices = items.map((_, i) => i);
94
+ for (let i = indices.length - 1; i > indices.length - k; i--) {
95
+ const j = i - Math.floor(crypto.randomInt(i + 1));
96
+ [indices[i], indices[j]] = [indices[j], indices[i]];
79
97
  }
80
- return mostRecent;
98
+ // The last k indices are the picked ones; sort them to keep newest-first order.
99
+ return indices
100
+ .slice(indices.length - k)
101
+ .sort((a, b) => a - b)
102
+ .map((i) => items[i]);
81
103
  }
82
104
  export function getClaudeSummaryFromFile(filePath, cwd = process.cwd()) {
83
105
  const rows = parseJsonLines(filePath);
@@ -107,6 +129,52 @@ export function getClaudeSummaryFromFile(filePath, cwd = process.cwd()) {
107
129
  ].join("\n")
108
130
  };
109
131
  }
132
+ /**
133
+ * How many of the most recent sessions to draw candidates from. Sampling a
134
+ * pool (not just the single newest) keeps the background context fresh across
135
+ * runs — the same question set isn't always anchored to whichever session
136
+ * happened to be touched last.
137
+ */
138
+ const RECENT_SESSION_POOL = 10;
139
+ /**
140
+ * How many sessions from the pool to actually feed into the prompt as
141
+ * background context. Three is enough breadth without bloating the prompt.
142
+ */
143
+ const SELECTED_SESSION_COUNT = 3;
144
+ /**
145
+ * Build a single {@link SourceSummary} from multiple session transcripts,
146
+ * each sectioned by its file name so the generator can tell them apart.
147
+ */
148
+ function mergeSessionSummaries(summaries, cwd) {
149
+ if (summaries.length === 1) {
150
+ return summaries[0];
151
+ }
152
+ const sections = summaries.map((s, i) => `### Session ${i + 1}: ${s.title}\n${s.summary}`);
153
+ return {
154
+ sourceType: "claude_session",
155
+ title: `${summaries.length} recent sessions`,
156
+ summary: [
157
+ `Claude project path: ${cwd}`,
158
+ `Selected ${summaries.length} sessions (random sample from the ${RECENT_SESSION_POOL} most recent) as background context:`,
159
+ sections.join("\n\n")
160
+ ].join("\n")
161
+ };
162
+ }
163
+ /**
164
+ * Resolve the background context for a session-mode quiz: take the
165
+ * {@link RECENT_SESSION_POOL} most recent transcripts globally, randomly pick
166
+ * {@link SELECTED_SESSION_COUNT} of them, and merge their summaries.
167
+ *
168
+ * Falls back gracefully when fewer than the pool size exist — it samples from
169
+ * whatever is available and throws only when there are no transcripts at all.
170
+ */
110
171
  export function getLatestClaudeSummary(cwd = process.cwd()) {
111
- return getClaudeSummaryFromFile(findLatestSessionFile(), cwd);
172
+ const pool = listAllSessionFiles().slice(0, RECENT_SESSION_POOL);
173
+ if (pool.length === 0) {
174
+ const projectsDir = getClaudeProjectsDir();
175
+ throw new Error(`No Claude project transcripts found in ${projectsDir}. Run Claude Code in this repo first, or use --repo / "topic".`);
176
+ }
177
+ const selected = pickRandom(pool, SELECTED_SESSION_COUNT);
178
+ const summaries = selected.map((file) => getClaudeSummaryFromFile(file, cwd));
179
+ return mergeSessionSummaries(summaries, cwd);
112
180
  }
@@ -1,7 +1,23 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
1
3
  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";
4
+ import { JsonStore } from "./json.js";
5
+ function ensureDir(dirPath) {
6
+ fs.mkdirSync(dirPath, { recursive: true });
7
+ }
8
+ export function getAppDataDir() {
9
+ if (process.env.QUIZME_DATA_DIR) {
10
+ return process.env.QUIZME_DATA_DIR;
11
+ }
12
+ const home = os.homedir();
13
+ if (process.platform === "darwin") {
14
+ return path.join(home, "Library", "Application Support", "quizme");
15
+ }
16
+ if (process.platform === "win32") {
17
+ return path.join(process.env.APPDATA || path.join(home, "AppData", "Roaming"), "quizme");
18
+ }
19
+ return path.join(process.env.XDG_DATA_HOME || path.join(home, ".local", "share"), "quizme");
20
+ }
5
21
  export function createStore() {
6
22
  let dataDir = getAppDataDir();
7
23
  try {
@@ -11,8 +27,7 @@ export function createStore() {
11
27
  dataDir = path.join(process.cwd(), ".quizme");
12
28
  ensureDir(dataDir);
13
29
  }
14
- const dbPath = path.join(dataDir, "history.sqlite");
15
- const store = new SqliteStore(dbPath);
30
+ const store = new JsonStore(path.join(dataDir, "quizme.json"));
16
31
  store.init();
17
32
  return store;
18
33
  }