@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 +29 -2
- package/dist/cli/config.js +19 -1
- package/dist/generation/prompts/quiz.js +57 -0
- package/dist/generation/prompts/why.js +30 -0
- package/dist/generation/schema.js +1 -1
- package/dist/providers/claudeAgent.js +138 -80
- package/dist/sources/claudeSession.js +80 -12
- package/dist/storage/index.js +20 -5
- package/dist/storage/json.js +227 -0
- package/dist/ui/App.js +7 -3
- package/dist/ui/components/AppHeader.js +5 -2
- package/dist/ui/components/SelectList.js +1 -1
- package/dist/ui/components/Spinner.js +16 -0
- package/dist/ui/components/StatusBar.js +1 -2
- package/dist/ui/components/TextInput.js +7 -4
- package/dist/ui/components/WelcomeBanner.js +3 -4
- package/dist/ui/screens/QuizScreen.js +12 -12
- package/dist/ui/screens/SettingsScreen.js +127 -1
- package/dist/ui/screens/SetupScreen.js +23 -14
- package/dist/ui/theme.js +6 -3
- package/package.json +5 -8
- package/dist/platform/fs.js +0 -17
- package/dist/platform/paths.js +0 -27
- package/dist/storage/sqlite.js +0 -294
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
|
-
- 离线
|
|
75
|
+
- 离线 provider(`QUIZME_PROVIDER=local`、`QUIZME_PROVIDER_FALLBACK=local`)为**暂未实现**的能力,当前不可用。
|
package/dist/cli/config.js
CHANGED
|
@@ -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
|
+
}
|
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
"
|
|
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
|
|
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 = [],
|
|
301
|
+
export async function generateQuestions({ source, config, recentQuestions, mode = "mixed", signals = [], onProgress }) {
|
|
245
302
|
ensureClaudeAvailable();
|
|
246
|
-
const prompt = buildQuizPrompt({ source, config, recentQuestions, mode, signals
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
68
|
+
return [];
|
|
58
69
|
}
|
|
59
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/storage/index.js
CHANGED
|
@@ -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 {
|
|
3
|
-
|
|
4
|
-
|
|
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
|
|
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
|
}
|