@flashlearnai/cli 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.
package/dist/index.js ADDED
@@ -0,0 +1,1840 @@
1
+ #!/usr/bin/env node
2
+ var __getOwnPropNames = Object.getOwnPropertyNames;
3
+ var __esm = (fn, res) => function __init() {
4
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
5
+ };
6
+
7
+ // packages/cli/src/paths.ts
8
+ import { join, resolve } from "node:path";
9
+ function projectRoot(input) {
10
+ return resolve(input);
11
+ }
12
+ function flashlearnRoot(input) {
13
+ return join(projectRoot(input), ".flashlearn");
14
+ }
15
+ var init_paths = __esm({
16
+ "packages/cli/src/paths.ts"() {
17
+ "use strict";
18
+ }
19
+ });
20
+
21
+ // packages/cli/src/yaml.ts
22
+ function toYaml(value, indent = 0) {
23
+ const space = " ".repeat(indent);
24
+ if (Array.isArray(value)) {
25
+ if (!value.length) return `${space}[]`;
26
+ return value.map((item) => {
27
+ if (isScalar(item)) return `${space}- ${yamlScalar(item)}`;
28
+ const nested = toYaml(item, indent + 2).split("\n");
29
+ return `${space}-${nested.map((line, index) => index === 0 ? ` ${line.trimStart()}` : `
30
+ ${line}`).join("")}`;
31
+ }).join("\n");
32
+ }
33
+ if (value && typeof value === "object") {
34
+ const entries = Object.entries(value);
35
+ if (!entries.length) return `${space}{}`;
36
+ return entries.map(([key, item]) => isScalar(item) ? `${space}${key}: ${yamlScalar(item)}` : `${space}${key}:
37
+ ${toYaml(item, indent + 2)}`).join("\n");
38
+ }
39
+ return `${space}${yamlScalar(value)}`;
40
+ }
41
+ function isScalar(value) {
42
+ return value === null || typeof value !== "object";
43
+ }
44
+ function yamlScalar(value) {
45
+ if (typeof value === "string") return JSON.stringify(value);
46
+ if (value === void 0) return "null";
47
+ return String(value);
48
+ }
49
+ var init_yaml = __esm({
50
+ "packages/cli/src/yaml.ts"() {
51
+ "use strict";
52
+ }
53
+ });
54
+
55
+ // packages/cli/src/cli.ts
56
+ import { isAbsolute, resolve as resolve2, sep } from "node:path";
57
+ async function runCli(args2, service2, io) {
58
+ try {
59
+ const parsed = parseProjectFlag(args2);
60
+ args2 = parsed.args;
61
+ const project = resolve2(io.cwd, parsed.directory ?? ".");
62
+ const help = helpFor(args2);
63
+ if (help) {
64
+ io.stdout(help);
65
+ return 0;
66
+ }
67
+ if (args2[0] === "--version" || args2[0] === "-v") {
68
+ if (args2.length !== 1) throw new UsageError("--version does not accept arguments");
69
+ io.stdout(CLI_VERSION);
70
+ return 0;
71
+ }
72
+ const [command, ...commandArgs] = args2;
73
+ if (command === "project") {
74
+ const [subcommand, ...subcommandArgs] = commandArgs;
75
+ if (subcommand === "set") {
76
+ throw new UsageError("project set was removed. Use --project <directory> on each command; the default is the working directory.");
77
+ }
78
+ if (subcommand === "show" || subcommand === "status") {
79
+ const { positional, format } = parseQuery(subcommandArgs);
80
+ if (positional.length) throw new UsageError(`project ${subcommand} does not accept arguments`);
81
+ io.stderr(`Project: ${project}`);
82
+ if (subcommand === "show") {
83
+ await service2.resolveProject(project);
84
+ io.stdout(formatValue({ project }, format, ({ project: path }) => path));
85
+ } else {
86
+ const status = await service2.status(project);
87
+ io.stdout(formatValue(status, format, formatStatus));
88
+ }
89
+ return 0;
90
+ }
91
+ throw new UsageError(`Unknown project command: ${subcommand ?? "(missing)"}`);
92
+ }
93
+ if (command === "question") {
94
+ const [subcommand, ...subcommandArgs] = commandArgs;
95
+ const { positional, format } = parseQuery(subcommandArgs);
96
+ if (subcommand === "get") {
97
+ if (positional.length !== 1) throw new UsageError("question get requires one card ID");
98
+ io.stderr(`Project: ${project}`);
99
+ const card = await service2.getCard(positional[0], project);
100
+ if (!card) throw new Error(`Card not found: ${positional[0]}`);
101
+ io.stdout(formatValue(card, format, formatCard));
102
+ } else if (subcommand === "list") {
103
+ if (positional.length) throw new UsageError("question list does not accept arguments");
104
+ io.stderr(`Project: ${project}`);
105
+ const cards = await service2.listCards(project);
106
+ io.stdout(formatValue(cards, format, formatCardList));
107
+ } else {
108
+ throw new UsageError(`Unknown question command: ${subcommand ?? "(missing)"}`);
109
+ }
110
+ return 0;
111
+ }
112
+ if (command === "init" || command === "generate") {
113
+ const { directory: directoryArgument, options } = command === "generate" ? parseGenerate(commandArgs) : { directory: parseDirectoryOnly(commandArgs), options: void 0 };
114
+ const directory = workflowDirectory(directoryArgument, parsed.directory, io.cwd);
115
+ io.stderr(`Project: ${directory}`);
116
+ if (command === "init") {
117
+ await service2.initialize(directory);
118
+ io.stdout(`Initialized ${flashlearnRoot(directory)}`);
119
+ io.stdout("");
120
+ io.stdout("Next:");
121
+ io.stdout(" # Generate study cards from this repository");
122
+ io.stdout(` flashlearn generate --project ${quoteArgument(directory)}`);
123
+ } else {
124
+ if (!await generateForStudy(service2, io, directory, options)) return 1;
125
+ io.stdout("");
126
+ io.stdout("Next:");
127
+ io.stdout(" # Start the local learning experience");
128
+ io.stdout(` flashlearn start --project ${quoteArgument(directory)}`);
129
+ }
130
+ return 0;
131
+ }
132
+ if (command === "start") {
133
+ const { directory: directoryArgument, options, yes } = parseStart(commandArgs);
134
+ const directory = workflowDirectory(directoryArgument, parsed.directory, io.cwd);
135
+ io.stderr(`Project: ${directory}`);
136
+ if (!(await service2.listCards(directory)).length) {
137
+ const generateCommand = `flashlearn generate --project ${quoteArgument(directory)}`;
138
+ io.stderr(`No study cards found in ${flashlearnRoot(directory)}.`);
139
+ const approved = yes || await io.confirm?.(`Run ${generateCommand} first? This initializes storage and may use the configured AI endpoint. [y/N] `);
140
+ if (!approved) {
141
+ io.stderr(`Required first step:
142
+ ${generateCommand}
143
+ Then:
144
+ flashlearn start --project ${quoteArgument(directory)}
145
+ Or approve empty-deck generation with start --yes.`);
146
+ return 1;
147
+ }
148
+ if (!await generateForStudy(service2, io, directory)) return 1;
149
+ }
150
+ await service2.start(directory, options);
151
+ const url = `http://${options.host ?? "localhost"}:${options.port ?? 4173}`;
152
+ io.stdout(`FlashLearn running at ${url}`);
153
+ io.stdout("");
154
+ io.stdout("Next:");
155
+ io.stdout(" # Open this URL in your browser to begin reviewing");
156
+ io.stdout(` ${url}`);
157
+ return 0;
158
+ }
159
+ throw new UsageError(`Unknown command: ${command}`);
160
+ } catch (error) {
161
+ const message = error instanceof Error ? error.message : "Unknown error";
162
+ io.stderr(`Error: ${message}`);
163
+ if (error instanceof UsageError) {
164
+ io.stderr("Run `flashlearn --help` for usage.");
165
+ return 2;
166
+ }
167
+ return 1;
168
+ }
169
+ }
170
+ async function generateForStudy(service2, io, directory, options) {
171
+ io.stderr("Generating study cards (may use the configured AI endpoint)...");
172
+ const cards = await service2.generate(directory, options);
173
+ io.stdout(`Generated and stored ${cards.length} card${cards.length === 1 ? "" : "s"} (new or updated).`);
174
+ const available = (await service2.listCards(directory)).length;
175
+ if (!available) {
176
+ io.stderr("No study cards are available. Add supported source/docs or check extraction configuration, then run generate again.");
177
+ return false;
178
+ }
179
+ io.stdout(`Study deck: ${available} card${available === 1 ? "" : "s"} available.`);
180
+ return true;
181
+ }
182
+ function workflowDirectory(positional, flag, cwd) {
183
+ if (positional !== void 0 && flag !== void 0) throw new UsageError("Use either a positional directory or --project, not both");
184
+ return resolve2(cwd, flag ?? positional ?? ".");
185
+ }
186
+ function parseProjectFlag(args2) {
187
+ const rest = [];
188
+ let directory;
189
+ for (let index = 0; index < args2.length; index += 1) {
190
+ const arg = args2[index];
191
+ if (arg === "--project" || arg === "-p" || arg.startsWith("--project=")) {
192
+ if (directory !== void 0) throw new UsageError("Specify --project only once");
193
+ directory = arg.startsWith("--project=") ? arg.slice("--project=".length) : requireValue(args2, ++index, arg);
194
+ if (!directory.trim()) throw new UsageError("--project requires a directory");
195
+ } else {
196
+ rest.push(arg);
197
+ }
198
+ }
199
+ return { args: rest, directory };
200
+ }
201
+ function helpFor(args2) {
202
+ if (args2.length === 0 || args2[0] === "--help" || args2[0] === "-h") return HELP;
203
+ const helpArgs = args2[0] === "help" ? args2.slice(1) : args2;
204
+ if (args2[0] === "help" && helpArgs.length === 0) return HELP;
205
+ const command = helpArgs[0];
206
+ if (!command) return null;
207
+ const subcommand = helpArgs[1];
208
+ const requested = args2[0] === "help" || helpArgs.includes("--help") || helpArgs.includes("-h");
209
+ const bareGroup = (command === "project" || command === "question") && helpArgs.length === 1;
210
+ if (!requested && !bareGroup) return null;
211
+ const key = subcommand && subcommand !== "--help" && subcommand !== "-h" ? `${command} ${subcommand}` : command;
212
+ const help = COMMAND_HELP[key] ?? COMMAND_HELP[command];
213
+ return help ? `${help}
214
+
215
+ Project option:
216
+ -p, --project <directory> Project directory (default: working directory)` : HELP;
217
+ }
218
+ function parseQuery(args2) {
219
+ const positional = [];
220
+ let format = "text";
221
+ for (let index = 0; index < args2.length; index += 1) {
222
+ const argument = args2[index];
223
+ if (argument === "-o" || argument === "--output") {
224
+ const value = requireValue(args2, ++index, argument);
225
+ if (value !== "text" && value !== "json" && value !== "yaml") {
226
+ throw new UsageError("--output must be text, json, or yaml");
227
+ }
228
+ format = value;
229
+ } else if (argument?.startsWith("-")) {
230
+ throw new UsageError(`Unknown option: ${argument}`);
231
+ } else if (argument) {
232
+ positional.push(argument);
233
+ }
234
+ }
235
+ return { positional, format };
236
+ }
237
+ function formatValue(value, format, text) {
238
+ if (format === "json") return JSON.stringify(value, null, 2);
239
+ if (format === "yaml") return toYaml(value);
240
+ return text(value);
241
+ }
242
+ function formatCard(card) {
243
+ const tags = card.tags?.length ? `
244
+ Tags: ${card.tags.join(", ")}` : "";
245
+ return `ID: ${card.id}
246
+ Question: ${card.question}
247
+ Answer: ${card.answer}
248
+ Source: ${card.source.path} @ ${card.source.sha}${tags}`;
249
+ }
250
+ function formatCardList(cards) {
251
+ return cards.length ? cards.map(({ id, question }) => `${id} ${question}`).join("\n") : "No cards found.";
252
+ }
253
+ function formatStatus(status) {
254
+ return `Project: ${status.project}
255
+ Cards: ${status.cards}
256
+ Reviewed: ${status.reviewed}
257
+ Unreviewed: ${status.unreviewed}
258
+ Due: ${status.due}`;
259
+ }
260
+ function quoteArgument(value) {
261
+ return `'${value.replaceAll("'", "'\\''")}'`;
262
+ }
263
+ function parseDirectoryOnly(args2) {
264
+ if (args2.some((arg) => arg.startsWith("-"))) throw new UsageError(`Unknown option: ${args2.find((arg) => arg.startsWith("-"))}`);
265
+ if (args2.length > 1) throw new UsageError("Expected at most one directory");
266
+ return args2[0];
267
+ }
268
+ function parseGenerate(args2) {
269
+ const positional = [];
270
+ const options = {};
271
+ for (let index = 0; index < args2.length; index += 1) {
272
+ const arg = args2[index];
273
+ if (arg === "--subpath") {
274
+ if (options.subpath !== void 0) throw new UsageError("Specify --subpath only once");
275
+ options.subpath = requireValue(args2, ++index, arg);
276
+ if (!options.subpath.trim()) throw new UsageError("--subpath requires a path");
277
+ if (isAbsolute(options.subpath) || options.subpath.split(sep).includes("..")) {
278
+ throw new UsageError("--subpath must be a repository-relative directory without '..'");
279
+ }
280
+ } else if (arg === "--max-files") {
281
+ if (options.maxFiles !== void 0) throw new UsageError("Specify --max-files only once");
282
+ const value = requireValue(args2, ++index, arg);
283
+ options.maxFiles = Number(value);
284
+ if (!/^\d+$/.test(value) || !Number.isSafeInteger(options.maxFiles) || options.maxFiles < 1) {
285
+ throw new UsageError("--max-files must be a positive integer");
286
+ }
287
+ } else {
288
+ positional.push(arg);
289
+ }
290
+ }
291
+ return { directory: parseDirectoryOnly(positional), options };
292
+ }
293
+ function parseStart(args2) {
294
+ let directory;
295
+ let yes = false;
296
+ let host;
297
+ let port;
298
+ for (let index = 0; index < args2.length; index += 1) {
299
+ const argument = args2[index];
300
+ if (argument === "--yes" || argument === "-y") {
301
+ yes = true;
302
+ } else if (argument === "--host") {
303
+ host = requireValue(args2, ++index, "--host");
304
+ if (!host.trim()) throw new UsageError("--host cannot be empty");
305
+ if (host === "0.0.0.0" || host === "::") throw new UsageError("--host must not use a wildcard address");
306
+ } else if (argument === "--port") {
307
+ const value = requireValue(args2, ++index, "--port");
308
+ port = Number(value);
309
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new UsageError("--port must be an integer from 1 to 65535");
310
+ } else if (argument?.startsWith("-")) {
311
+ throw new UsageError(`Unknown option: ${argument}`);
312
+ } else if (argument) {
313
+ if (directory) throw new UsageError("Expected at most one directory");
314
+ directory = argument;
315
+ }
316
+ }
317
+ return { directory, options: { host, port }, yes };
318
+ }
319
+ function requireValue(args2, index, option) {
320
+ const value = args2[index];
321
+ if (!value || value.startsWith("-")) throw new UsageError(`${option} requires a value`);
322
+ return value;
323
+ }
324
+ var CLI_VERSION, HELP, COMMAND_HELP, UsageError;
325
+ var init_cli = __esm({
326
+ "packages/cli/src/cli.ts"() {
327
+ "use strict";
328
+ init_paths();
329
+ init_yaml();
330
+ CLI_VERSION = "0.0.0";
331
+ HELP = `Usage: flashlearn <command> [directory] [options]
332
+
333
+ Commands:
334
+ init [directory] Create empty storage (optional)
335
+ generate [directory] Initialize storage and generate cards
336
+ start [directory] Start the local learning server
337
+ project show Show this invocation's project directory
338
+ project status Show project learning status
339
+ question list List generated questions
340
+ question get <card-id> Get one question and answer
341
+
342
+ Start options:
343
+ --host <host> Host to bind (default: localhost)
344
+ --port <port> Port to bind (default: 4173)
345
+ -y, --yes Generate cards for an empty deck without prompting
346
+
347
+ Generate options:
348
+ --subpath <path> Scan a repository-relative directory
349
+ --max-files <number> Scan at most this many supported files
350
+
351
+ Query options:
352
+ -o, --output <format> Output as text, json, or yaml (default: text)
353
+
354
+ General options:
355
+ -p, --project <directory> Project directory (default: working directory)
356
+ -h, --help Show help
357
+ -v, --version Show version`;
358
+ COMMAND_HELP = {
359
+ init: `Usage: flashlearn init [directory] [options]
360
+
361
+ Optional: create empty .flashlearn storage. Generate performs this step automatically.`,
362
+ generate: `Usage: flashlearn generate [directory] [options]
363
+
364
+ Initialize missing storage and generate questions. No separate init is needed.
365
+
366
+ Options:
367
+ --subpath <path> Scan a repository-relative directory
368
+ --max-files <number> Positive integer limit on scanned supported files`,
369
+ start: `Usage: flashlearn start [directory] [options]
370
+
371
+ Start the local learning server. Offer generation if the deck is empty.
372
+
373
+ Options:
374
+ --host <host> Host to bind (default: localhost)
375
+ --port <port> Port to bind (default: 4173)
376
+ -y, --yes Approve empty-deck generation (may use the configured AI endpoint)`,
377
+ project: `Usage: flashlearn project <command> [options]
378
+
379
+ Commands:
380
+ show Show this invocation's project directory
381
+ status Show project learning status
382
+
383
+ Options:
384
+ -o, --output <format> text, json, or yaml`,
385
+ "project show": `Usage: flashlearn project show [options]
386
+
387
+ Show this invocation's project directory.
388
+
389
+ Options:
390
+ -o, --output <format> text, json, or yaml`,
391
+ "project status": `Usage: flashlearn project status [options]
392
+
393
+ Show card and review counts for this invocation's project.
394
+
395
+ Options:
396
+ -o, --output <format> text, json, or yaml`,
397
+ question: `Usage: flashlearn question <command> [options]
398
+
399
+ Commands:
400
+ list List generated questions
401
+ get <card-id> Get one question, answer, and source
402
+
403
+ Options:
404
+ -o, --output <format> text, json, or yaml`,
405
+ "question list": `Usage: flashlearn question list [options]
406
+
407
+ List questions from this invocation's project.
408
+
409
+ Options:
410
+ -o, --output <format> text, json, or yaml`,
411
+ "question get": `Usage: flashlearn question get <card-id> [options]
412
+
413
+ Get one question, answer, and source from this invocation's project.
414
+
415
+ Options:
416
+ -o, --output <format> text, json, or yaml`
417
+ };
418
+ UsageError = class extends Error {
419
+ };
420
+ }
421
+ });
422
+
423
+ // packages/extraction/dist/extractor.js
424
+ import { execFile } from "node:child_process";
425
+ import { open, readdir } from "node:fs/promises";
426
+ import { extname, join as join2, relative, sep as sep2 } from "node:path";
427
+ import { promisify } from "node:util";
428
+ function isGoTestPath(path) {
429
+ return /_test\.go$/i.test(path);
430
+ }
431
+ function isGeneratedPath(path) {
432
+ return GENERATED_NAME.test(path) || CHANGELOG_NAME.test(path);
433
+ }
434
+ function isGeneratedContent(head) {
435
+ return GENERATED_MARKER.test(head);
436
+ }
437
+ async function sniff(path) {
438
+ const handle = await open(path, "r");
439
+ try {
440
+ const buffer = Buffer.alloc(SNIFF_BYTES);
441
+ const { bytesRead } = await handle.read(buffer, 0, SNIFF_BYTES, 0);
442
+ return buffer.subarray(0, bytesRead).toString("utf8");
443
+ } finally {
444
+ await handle.close();
445
+ }
446
+ }
447
+ async function isSkipped(path) {
448
+ if (isGoTestPath(path) || isGeneratedPath(path))
449
+ return true;
450
+ if (extname(path).toLowerCase() !== ".go")
451
+ return false;
452
+ try {
453
+ return isGeneratedContent(await sniff(path));
454
+ } catch {
455
+ return false;
456
+ }
457
+ }
458
+ async function sourceFiles(root, directory = root) {
459
+ const entries = await readdir(directory, { withFileTypes: true });
460
+ const paths = await Promise.all(entries.map(async (entry) => {
461
+ const path = join2(directory, entry.name);
462
+ if (entry.isDirectory())
463
+ return IGNORED_DIRECTORIES.has(entry.name) ? [] : sourceFiles(root, path);
464
+ if (!entry.isFile() || !SOURCE_EXTENSIONS.has(extname(entry.name)))
465
+ return [];
466
+ return await isSkipped(path) ? [] : [path];
467
+ }));
468
+ return paths.flat();
469
+ }
470
+ function toRepositoryPath(root, absolutePath) {
471
+ return relative(root, absolutePath).split(sep2).join("/");
472
+ }
473
+ async function headSha(root) {
474
+ try {
475
+ const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: root });
476
+ return stdout.trim();
477
+ } catch {
478
+ return "unknown";
479
+ }
480
+ }
481
+ async function fileSha(root, repositoryPath, fallback) {
482
+ try {
483
+ const { stdout } = await execFileAsync("git", ["rev-parse", `HEAD:${repositoryPath}`], { cwd: root });
484
+ const sha = stdout.trim();
485
+ return sha.length > 0 ? sha : fallback;
486
+ } catch {
487
+ return fallback;
488
+ }
489
+ }
490
+ var execFileAsync, SOURCE_EXTENSIONS, IGNORED_DIRECTORIES, GENERATED_NAME, CHANGELOG_NAME, GENERATED_MARKER, SNIFF_BYTES;
491
+ var init_extractor = __esm({
492
+ "packages/extraction/dist/extractor.js"() {
493
+ "use strict";
494
+ execFileAsync = promisify(execFile);
495
+ SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".go", ".js", ".jsx", ".md", ".ts", ".tsx"]);
496
+ IGNORED_DIRECTORIES = /* @__PURE__ */ new Set([
497
+ ".git",
498
+ ".flashlearn",
499
+ "_output",
500
+ "build",
501
+ "coverage",
502
+ "dist",
503
+ "node_modules",
504
+ "testdata",
505
+ "third_party",
506
+ "vendor"
507
+ ]);
508
+ GENERATED_NAME = /(^|[./_-])(zz_generated|bindata)|\.pb\.go$|_generated\.go$|(^|\/)generated\.go$/i;
509
+ CHANGELOG_NAME = /(^|\/)changelog[^/]*\.md$/i;
510
+ GENERATED_MARKER = /^\/\/ Code generated .* DO NOT EDIT\.$/m;
511
+ SNIFF_BYTES = 2048;
512
+ }
513
+ });
514
+
515
+ // packages/extraction/dist/extractors.js
516
+ function isMetaDocument(path) {
517
+ const lower = path.toLowerCase();
518
+ if (META_DIRECTORIES.some((directory) => `/${lower}`.includes(directory)))
519
+ return true;
520
+ const name = lower.split("/").pop() ?? "";
521
+ return META_DOCUMENTS.has(name) || name.startsWith("claude") || name.startsWith("pull_request_template") || name.startsWith("issue_template");
522
+ }
523
+ function clamp(text) {
524
+ if (text.length <= MAX_ANSWER_LENGTH)
525
+ return text;
526
+ const window = text.slice(0, MAX_ANSWER_LENGTH);
527
+ const sentenceEnd = Math.max(window.lastIndexOf(". "), window.lastIndexOf("! "), window.lastIndexOf("? "));
528
+ if (sentenceEnd > MAX_ANSWER_LENGTH * 0.5)
529
+ return window.slice(0, sentenceEnd + 1);
530
+ const wordEnd = window.lastIndexOf(" ");
531
+ return `${(wordEnd > 0 ? window.slice(0, wordEnd) : window).trimEnd()}\u2026`;
532
+ }
533
+ function normalizeAnswer(text) {
534
+ return clamp(text.replace(/\s+/g, " ").trim());
535
+ }
536
+ function joinBody(lines) {
537
+ const parts = [];
538
+ for (const line of lines) {
539
+ const isItem = /^\s*(?:[-*+]|\d+\.)\s+/.test(line);
540
+ if (isItem || parts.length === 0) {
541
+ parts.push(line.trim());
542
+ } else if (/^\s*(?:[-*+]|\d+\.)\s+/.test(parts[parts.length - 1] ?? "")) {
543
+ parts.push(line.trim());
544
+ } else {
545
+ parts[parts.length - 1] = `${parts[parts.length - 1]} ${line.trim()}`;
546
+ }
547
+ }
548
+ return clamp(parts.map((part) => part.replace(/\s+/g, " ").trim()).join("\n"));
549
+ }
550
+ function plainHeading(text) {
551
+ return text.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1").replace(/[*_`#]/g, "").trim();
552
+ }
553
+ function isGoSource(path) {
554
+ const lower = path.toLowerCase();
555
+ return lower.endsWith(".go") && !lower.endsWith("_test.go");
556
+ }
557
+ function goDeclaration(line) {
558
+ const match = /^(func|type|const|var)\s+([A-Z][\w]*)/.exec(line);
559
+ if (!match?.[1] || !match[2])
560
+ return null;
561
+ return { kind: match[1], name: match[2] };
562
+ }
563
+ function goSubject(declaration) {
564
+ return declaration.kind === "func" ? `${declaration.name}()` : declaration.name;
565
+ }
566
+ var MAX_ANSWER_LENGTH, META_DOCUMENTS, META_DIRECTORIES, MarkdownExtractor, JsDocExtractor, GoDocExtractor, ExportSignatureExtractor, CompositeExtractor;
567
+ var init_extractors = __esm({
568
+ "packages/extraction/dist/extractors.js"() {
569
+ "use strict";
570
+ MAX_ANSWER_LENGTH = 700;
571
+ META_DOCUMENTS = /* @__PURE__ */ new Set([
572
+ "agents.md",
573
+ "changelog.md",
574
+ "code_of_conduct.md",
575
+ "contributing.md",
576
+ "license.md",
577
+ "security.md"
578
+ ]);
579
+ META_DIRECTORIES = ["/.github/", "/docs/devel/"];
580
+ MarkdownExtractor = class {
581
+ async extract(input) {
582
+ if (!input.path.toLowerCase().endsWith(".md"))
583
+ return [];
584
+ if (isMetaDocument(input.path))
585
+ return [];
586
+ const cards = [];
587
+ const lines = input.content.split(/\r?\n/);
588
+ let heading = null;
589
+ let body = [];
590
+ let inFence = false;
591
+ const flush = () => {
592
+ const answer = joinBody(body);
593
+ if (heading && answer.length > 0) {
594
+ cards.push({
595
+ question: `What does "${heading}" cover?`,
596
+ answer,
597
+ source: { path: input.path, sha: input.sha }
598
+ });
599
+ }
600
+ body = [];
601
+ };
602
+ for (const line of lines) {
603
+ if (/^\s*```/.test(line)) {
604
+ inFence = !inFence;
605
+ continue;
606
+ }
607
+ if (inFence)
608
+ continue;
609
+ const match = /^(#{1,6})\s+(.*\S)\s*$/.exec(line);
610
+ if (match?.[2]) {
611
+ flush();
612
+ heading = plainHeading(match[2]);
613
+ continue;
614
+ }
615
+ if (heading && line.trim().length > 0 && !/^\s*\|/.test(line)) {
616
+ body.push(line.trim());
617
+ }
618
+ }
619
+ flush();
620
+ return cards;
621
+ }
622
+ };
623
+ JsDocExtractor = class {
624
+ async extract(input) {
625
+ if (!/\.(ts|tsx|js|jsx)$/i.test(input.path))
626
+ return [];
627
+ const cards = [];
628
+ const pattern = /\/\*\*([\s\S]*?)\*\/\s*export\s+(?:default\s+)?(?:abstract\s+)?(?:async\s+)?(function|class|const|let|interface|type|enum)\s+([A-Za-z_$][\w$]*)/g;
629
+ for (const match of input.content.matchAll(pattern)) {
630
+ const [, rawDoc, kind, name] = match;
631
+ if (!rawDoc || !kind || !name)
632
+ continue;
633
+ const summary = rawDoc.split(/\r?\n/).map((line) => line.replace(/^\s*\*+/, "").trim()).filter((line) => line.length > 0 && !line.startsWith("@")).join(" ");
634
+ const answer = normalizeAnswer(summary);
635
+ if (answer.length === 0)
636
+ continue;
637
+ const subject = kind === "function" ? `${name}()` : name;
638
+ cards.push({
639
+ question: `What does \`${subject}\` do?`,
640
+ answer,
641
+ source: { path: input.path, sha: input.sha }
642
+ });
643
+ }
644
+ return cards;
645
+ }
646
+ };
647
+ GoDocExtractor = class {
648
+ async extract(input) {
649
+ if (!isGoSource(input.path))
650
+ return [];
651
+ const cards = [];
652
+ const lines = input.content.split(/\r?\n/);
653
+ let comment = [];
654
+ for (const line of lines) {
655
+ const commentMatch = /^\s*\/\/\s?(.*)$/.exec(line);
656
+ if (commentMatch) {
657
+ comment.push((commentMatch[1] ?? "").trim());
658
+ continue;
659
+ }
660
+ const declaration = goDeclaration(line);
661
+ if (declaration && comment.length > 0) {
662
+ const answer = normalizeAnswer(comment.join(" "));
663
+ if (answer.length > 0) {
664
+ cards.push({
665
+ question: `What does \`${goSubject(declaration)}\` do?`,
666
+ answer,
667
+ source: { path: input.path, sha: input.sha }
668
+ });
669
+ }
670
+ }
671
+ comment = [];
672
+ }
673
+ return cards;
674
+ }
675
+ };
676
+ ExportSignatureExtractor = class {
677
+ async extract(input) {
678
+ if (isGoSource(input.path))
679
+ return this.extractGo(input);
680
+ if (!/\.(ts|tsx|js|jsx)$/i.test(input.path))
681
+ return [];
682
+ const documented = /* @__PURE__ */ new Set();
683
+ const documentedPattern = /\/\*\*[\s\S]*?\*\/\s*export\s+(?:default\s+)?(?:abstract\s+)?(?:async\s+)?(?:function|class|const|let|interface|type|enum)\s+([A-Za-z_$][\w$]*)/g;
684
+ for (const match of input.content.matchAll(documentedPattern)) {
685
+ if (match[1])
686
+ documented.add(match[1]);
687
+ }
688
+ const cards = [];
689
+ const pattern = /^export\s+(?:default\s+)?(?:abstract\s+)?(?:async\s+)?(function|class|interface|type|enum)\s+([A-Za-z_$][\w$]*)/gm;
690
+ for (const match of input.content.matchAll(pattern)) {
691
+ const [, kind, name] = match;
692
+ if (!kind || !name || documented.has(name))
693
+ continue;
694
+ cards.push({
695
+ question: `Which file defines the \`${name}\` ${kind}?`,
696
+ answer: `\`${name}\` is an exported ${kind} defined in \`${input.path}\`.`,
697
+ source: { path: input.path, sha: input.sha }
698
+ });
699
+ }
700
+ return cards;
701
+ }
702
+ /** Exported Go declarations with no preceding doc comment become locator cards. */
703
+ async extractGo(input) {
704
+ const cards = [];
705
+ const lines = input.content.split(/\r?\n/);
706
+ let documented = false;
707
+ for (const line of lines) {
708
+ if (/^\s*\/\//.test(line)) {
709
+ documented = true;
710
+ continue;
711
+ }
712
+ const declaration = goDeclaration(line);
713
+ if (declaration && !documented) {
714
+ cards.push({
715
+ question: `Which file defines the \`${declaration.name}\` ${declaration.kind}?`,
716
+ answer: `\`${declaration.name}\` is an exported ${declaration.kind} defined in \`${input.path}\`.`,
717
+ source: { path: input.path, sha: input.sha }
718
+ });
719
+ }
720
+ documented = false;
721
+ }
722
+ return cards;
723
+ }
724
+ };
725
+ CompositeExtractor = class {
726
+ extractors;
727
+ constructor(...extractors) {
728
+ this.extractors = extractors;
729
+ }
730
+ async extract(input) {
731
+ const results = await Promise.all(this.extractors.map(async (extractor) => extractor.extract(input)));
732
+ return results.flat();
733
+ }
734
+ };
735
+ }
736
+ });
737
+
738
+ // packages/extraction/dist/endpoint.js
739
+ function endpointConfigFromEnv(env = process.env) {
740
+ const url = env[ENDPOINT_ENV.url]?.trim();
741
+ const model = env[ENDPOINT_ENV.model]?.trim();
742
+ if (!url || !model)
743
+ return null;
744
+ const apiKey = env[ENDPOINT_ENV.apiKey]?.trim();
745
+ const authHeader = env[ENDPOINT_ENV.authHeader]?.trim();
746
+ return {
747
+ url,
748
+ model,
749
+ ...apiKey ? { apiKey } : {},
750
+ ...authHeader ? { authHeader } : {}
751
+ };
752
+ }
753
+ function buildPrompt(input, maxCards) {
754
+ const content = input.content.length > MAX_CONTENT_CHARS ? `${input.content.slice(0, MAX_CONTENT_CHARS)}
755
+ \u2026 file truncated \u2026` : input.content;
756
+ return `File: ${input.path}
757
+ Write at most ${maxCards} cards.
758
+
759
+ ${content}`;
760
+ }
761
+ function parseCards(reply) {
762
+ const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(reply);
763
+ const candidate = fenced?.[1]?.trim() ?? reply.trim();
764
+ const start = candidate.indexOf("{");
765
+ const end = candidate.lastIndexOf("}");
766
+ if (start === -1 || end <= start)
767
+ return [];
768
+ let parsed;
769
+ try {
770
+ parsed = JSON.parse(candidate.slice(start, end + 1));
771
+ } catch {
772
+ return [];
773
+ }
774
+ const cards = parsed.cards;
775
+ if (!Array.isArray(cards))
776
+ return [];
777
+ return cards.flatMap((entry) => {
778
+ if (typeof entry !== "object" || entry === null)
779
+ return [];
780
+ const { question, answer } = entry;
781
+ if (typeof question !== "string" || typeof answer !== "string")
782
+ return [];
783
+ if (question.trim().length === 0 || answer.trim().length === 0)
784
+ return [];
785
+ return [{ question: question.trim(), answer: answer.trim() }];
786
+ });
787
+ }
788
+ var CODE_EXTENSIONS, MAX_CONTENT_CHARS, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_CARDS, ENDPOINT_ENV, SYSTEM_PROMPT, EndpointExtractor;
789
+ var init_endpoint = __esm({
790
+ "packages/extraction/dist/endpoint.js"() {
791
+ "use strict";
792
+ CODE_EXTENSIONS = /\.(go|js|jsx|ts|tsx)$/i;
793
+ MAX_CONTENT_CHARS = 24e3;
794
+ DEFAULT_TIMEOUT_MS = 3e4;
795
+ DEFAULT_MAX_CARDS = 5;
796
+ ENDPOINT_ENV = {
797
+ url: "FLASHLEARN_ENDPOINT_URL",
798
+ model: "FLASHLEARN_ENDPOINT_MODEL",
799
+ apiKey: "FLASHLEARN_ENDPOINT_API_KEY",
800
+ authHeader: "FLASHLEARN_ENDPOINT_AUTH_HEADER"
801
+ };
802
+ SYSTEM_PROMPT = [
803
+ "You write flashcards that help an engineer onboard to an unfamiliar codebase.",
804
+ "Given one source file, produce questions a newcomer would genuinely ask and answers grounded only in the file.",
805
+ "Prefer questions about behavior, control flow, and intent over restating names.",
806
+ "Never invent APIs, file paths, or behavior that is not present in the file.",
807
+ 'Reply with JSON only: {"cards":[{"question":"...","answer":"..."}]}.',
808
+ "Return an empty cards array when the file has nothing worth asking about."
809
+ ].join(" ");
810
+ EndpointExtractor = class {
811
+ config;
812
+ fetchImpl;
813
+ constructor(config, fetchImpl = fetch) {
814
+ this.config = config;
815
+ this.fetchImpl = fetchImpl;
816
+ }
817
+ async extract(input) {
818
+ if (!CODE_EXTENSIONS.test(input.path))
819
+ return [];
820
+ if (input.content.trim().length === 0)
821
+ return [];
822
+ const maxCards = this.config.maxCardsPerFile ?? DEFAULT_MAX_CARDS;
823
+ const reply = await this.requestReply(buildPrompt(input, maxCards));
824
+ if (reply === null)
825
+ return [];
826
+ return parseCards(reply).slice(0, maxCards).map((card) => ({
827
+ question: card.question,
828
+ answer: card.answer,
829
+ source: { path: input.path, sha: input.sha }
830
+ }));
831
+ }
832
+ /** Returns the assistant message, or null when the call fails for any reason. */
833
+ async requestReply(prompt) {
834
+ const controller = new AbortController();
835
+ const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS);
836
+ try {
837
+ const response = await this.fetchImpl(this.config.url, {
838
+ method: "POST",
839
+ headers: this.headers(),
840
+ body: JSON.stringify({
841
+ model: this.config.model,
842
+ messages: [
843
+ { role: "system", content: SYSTEM_PROMPT },
844
+ { role: "user", content: prompt }
845
+ ],
846
+ temperature: 0
847
+ }),
848
+ signal: controller.signal
849
+ });
850
+ if (!response.ok)
851
+ return null;
852
+ const payload = await response.json();
853
+ const content = payload.choices?.[0]?.message?.content;
854
+ return typeof content === "string" ? content : null;
855
+ } catch {
856
+ return null;
857
+ } finally {
858
+ clearTimeout(timeout);
859
+ }
860
+ }
861
+ headers() {
862
+ const headers = { "content-type": "application/json" };
863
+ if (!this.config.apiKey)
864
+ return headers;
865
+ const header = this.config.authHeader ?? "authorization";
866
+ headers[header] = header.toLowerCase() === "authorization" ? `Bearer ${this.config.apiKey}` : this.config.apiKey;
867
+ return headers;
868
+ }
869
+ };
870
+ }
871
+ });
872
+
873
+ // packages/extraction/dist/validator.js
874
+ function contentWords(text) {
875
+ return text.replace(/[`*_]/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter((word) => word.length > 1 && !STOP_WORDS.has(word)).map(stem);
876
+ }
877
+ function stem(word) {
878
+ for (const suffix of ["ements", "ement", "ations", "ation", "ings", "ing", "ers", "er", "es", "s"]) {
879
+ if (word.length > suffix.length + 2 && word.endsWith(suffix)) {
880
+ return word.slice(0, word.length - suffix.length);
881
+ }
882
+ }
883
+ return word;
884
+ }
885
+ function questionOverlap(question, answer) {
886
+ const answerWords = contentWords(answer);
887
+ if (answerWords.length === 0)
888
+ return 1;
889
+ const asked = new Set(contentWords(question));
890
+ const shared = answerWords.filter((word) => asked.has(word)).length;
891
+ return shared / answerWords.length;
892
+ }
893
+ function repairVagueQuestion(card) {
894
+ const askedAbout = QUESTION_SUBJECT.exec(card.question.trim());
895
+ if (!askedAbout)
896
+ return card;
897
+ const [, subject, call = ""] = askedAbout;
898
+ const answered = ANSWER_SUBJECT.exec(card.answer.trim());
899
+ if (!subject || !answered?.[1])
900
+ return card;
901
+ const fuller = answered[1];
902
+ const extendsSubject = fuller.length > subject.length && fuller.toLowerCase().endsWith(subject.toLowerCase());
903
+ if (!extendsSubject)
904
+ return card;
905
+ return { ...card, question: `What does \`${fuller}${call}\` do?` };
906
+ }
907
+ function validateCards(input) {
908
+ const kept = [];
909
+ const rejected = [];
910
+ const seenQuestions = /* @__PURE__ */ new Set();
911
+ for (const original of input) {
912
+ const card = repairVagueQuestion(original);
913
+ const reason = Object.values(CARD_RULES).reduce((found, rule) => found ?? rule(card), null);
914
+ if (reason) {
915
+ rejected.push({ card, reason });
916
+ continue;
917
+ }
918
+ const key = card.question.trim().toLowerCase().replace(/\s+/g, " ");
919
+ if (seenQuestions.has(key)) {
920
+ rejected.push({ card, reason: "duplicate question across files" });
921
+ continue;
922
+ }
923
+ seenQuestions.add(key);
924
+ kept.push(card);
925
+ }
926
+ return { cards: kept, rejected };
927
+ }
928
+ function rejectionSummary(rejected) {
929
+ const counts = /* @__PURE__ */ new Map();
930
+ for (const entry of rejected)
931
+ counts.set(entry.reason, (counts.get(entry.reason) ?? 0) + 1);
932
+ return [...counts.entries()].map(([reason, cards]) => ({ reason, cards })).sort((left, right) => right.cards - left.cards || left.reason.localeCompare(right.reason));
933
+ }
934
+ var MIN_ANSWER_LENGTH, MAX_QUESTION_OVERLAP, LOCATOR_QUESTION, DANGLING_OPENER, STOP_WORDS, CARD_RULES, QUESTION_SUBJECT, ANSWER_SUBJECT;
935
+ var init_validator = __esm({
936
+ "packages/extraction/dist/validator.js"() {
937
+ "use strict";
938
+ MIN_ANSWER_LENGTH = 25;
939
+ MAX_QUESTION_OVERLAP = 0.6;
940
+ LOCATOR_QUESTION = /^which file defines the /i;
941
+ DANGLING_OPENER = /^(this|it|its|these|those|that|they|the above|the below|the following|see above|see below|as described above|here|such)\b/i;
942
+ STOP_WORDS = /* @__PURE__ */ new Set([
943
+ "a",
944
+ "an",
945
+ "and",
946
+ "are",
947
+ "as",
948
+ "at",
949
+ "be",
950
+ "by",
951
+ "do",
952
+ "does",
953
+ "for",
954
+ "from",
955
+ "has",
956
+ "in",
957
+ "is",
958
+ "of",
959
+ "on",
960
+ "or",
961
+ "that",
962
+ "the",
963
+ "to",
964
+ "what",
965
+ "when",
966
+ "which",
967
+ "with"
968
+ ]);
969
+ CARD_RULES = {
970
+ locator: (card) => LOCATOR_QUESTION.test(card.question.trim()) ? "locator question" : null,
971
+ shortAnswer: (card) => card.answer.trim().length < MIN_ANSWER_LENGTH ? "answer too short to teach anything" : null,
972
+ restatement: (card) => questionOverlap(card.question, card.answer) > MAX_QUESTION_OVERLAP ? "answer restates the question" : null,
973
+ danglingContext: (card) => DANGLING_OPENER.test(card.answer.trim()) ? "answer depends on context the card omits" : null
974
+ };
975
+ QUESTION_SUBJECT = /^What does `([A-Za-z_][A-Za-z0-9_]*)(\(\))?` do\?$/;
976
+ ANSWER_SUBJECT = /^([A-Za-z_][A-Za-z0-9_]*)\b/;
977
+ }
978
+ });
979
+
980
+ // packages/extraction/dist/workstream.js
981
+ import { readFile } from "node:fs/promises";
982
+ import { join as join3, sep as sep3 } from "node:path";
983
+ function deterministicExtractor() {
984
+ return new CompositeExtractor(new JsDocExtractor(), new GoDocExtractor(), new ExportSignatureExtractor(), new MarkdownExtractor());
985
+ }
986
+ function defaultExtractor() {
987
+ const config = endpointConfigFromEnv();
988
+ if (!config)
989
+ return deterministicExtractor();
990
+ return new CompositeExtractor(new EndpointExtractor(config), new MarkdownExtractor());
991
+ }
992
+ function normalizeSubpath(subpath) {
993
+ const cleaned = subpath.split(sep3).join("/").replace(/^\.\//, "").replace(/^\/+|\/+$/g, "");
994
+ if (cleaned.length === 0)
995
+ return "";
996
+ if (cleaned === ".." || cleaned.startsWith("../") || cleaned.includes("/../")) {
997
+ throw new Error(`Subpath must stay inside the repository: ${subpath}`);
998
+ }
999
+ return cleaned;
1000
+ }
1001
+ function usableCards(cards) {
1002
+ const seen = /* @__PURE__ */ new Set();
1003
+ return cards.filter((card) => {
1004
+ if (card.question.trim().length === 0 || card.answer.trim().length === 0)
1005
+ return false;
1006
+ const key = `${card.source.path}::${card.question.trim()}`;
1007
+ if (seen.has(key))
1008
+ return false;
1009
+ seen.add(key);
1010
+ return true;
1011
+ });
1012
+ }
1013
+ async function mapWithConcurrency(items, limit, task) {
1014
+ const results = new Array(items.length);
1015
+ let next = 0;
1016
+ const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
1017
+ while (next < items.length) {
1018
+ const index = next;
1019
+ next += 1;
1020
+ const item = items[index];
1021
+ if (item !== void 0)
1022
+ results[index] = await task(item);
1023
+ }
1024
+ });
1025
+ await Promise.all(workers);
1026
+ return results;
1027
+ }
1028
+ async function generateCards(root, extractor = defaultExtractor(), options = {}) {
1029
+ return new ExtractionService(extractor).generateFromRepository(root, options);
1030
+ }
1031
+ var DEFAULT_CONCURRENCY, ExtractionService;
1032
+ var init_workstream = __esm({
1033
+ "packages/extraction/dist/workstream.js"() {
1034
+ "use strict";
1035
+ init_extractor();
1036
+ init_extractors();
1037
+ init_endpoint();
1038
+ init_validator();
1039
+ DEFAULT_CONCURRENCY = 8;
1040
+ ExtractionService = class {
1041
+ extractor;
1042
+ concurrency;
1043
+ constructor(extractor = defaultExtractor(), concurrency = DEFAULT_CONCURRENCY) {
1044
+ this.extractor = extractor;
1045
+ this.concurrency = concurrency;
1046
+ }
1047
+ /**
1048
+ * Scans the repository, optionally restricted to a subpath. The root stays
1049
+ * the repository root even when scoped, so Git attribution keeps resolving
1050
+ * and `source.path` remains repository-relative.
1051
+ */
1052
+ async scanRepository(root, options = {}) {
1053
+ const commitSha = await headSha(root);
1054
+ const subpath = options.subpath ? normalizeSubpath(options.subpath) : "";
1055
+ let files = await sourceFiles(root, subpath ? join3(root, subpath) : root);
1056
+ files.sort((left, right) => left.localeCompare(right));
1057
+ if (options.maxFiles !== void 0)
1058
+ files = files.slice(0, Math.max(0, options.maxFiles));
1059
+ const documents = await Promise.all(files.map(async (absolutePath) => {
1060
+ const path = toRepositoryPath(root, absolutePath);
1061
+ const [content, sha] = await Promise.all([
1062
+ readFile(absolutePath, "utf8"),
1063
+ fileSha(root, path, commitSha)
1064
+ ]);
1065
+ return { path, content, sha };
1066
+ }));
1067
+ return documents.sort((left, right) => left.path.localeCompare(right.path));
1068
+ }
1069
+ /**
1070
+ * Generates cards for one document. Per-document validation cannot see
1071
+ * repeats across files, so cross-file deduplication happens in
1072
+ * `generateFromRepository`.
1073
+ */
1074
+ async generateFromDocument(document) {
1075
+ const cards = await this.extractor.extract(document);
1076
+ return validateCards(usableCards(cards)).cards;
1077
+ }
1078
+ async generateFromRepository(root, options = {}) {
1079
+ return (await this.generateWithRejections(root, options)).cards;
1080
+ }
1081
+ /**
1082
+ * Repository-wide generation that also reports what validation filtered.
1083
+ * The corpus report uses this to show why a run shrank.
1084
+ */
1085
+ async generateWithRejections(root, options = {}) {
1086
+ const documents = await this.scanRepository(root, options);
1087
+ const generated = await mapWithConcurrency(documents, this.concurrency, async (document) => usableCards(await this.extractor.extract(document)));
1088
+ return validateCards(generated.flat());
1089
+ }
1090
+ };
1091
+ }
1092
+ });
1093
+
1094
+ // packages/extraction/dist/report.js
1095
+ import { extname as extname2 } from "node:path";
1096
+ function median(sorted) {
1097
+ if (sorted.length === 0)
1098
+ return 0;
1099
+ const middle = Math.floor(sorted.length / 2);
1100
+ if (sorted.length % 2 === 1)
1101
+ return sorted[middle] ?? 0;
1102
+ return Math.round(((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2);
1103
+ }
1104
+ function summarizeCards(cards) {
1105
+ const perFile = /* @__PURE__ */ new Map();
1106
+ const perExtension = /* @__PURE__ */ new Map();
1107
+ const questionCounts = /* @__PURE__ */ new Map();
1108
+ const lengths = [];
1109
+ for (const card of cards) {
1110
+ const path = card.source.path;
1111
+ perFile.set(path, (perFile.get(path) ?? 0) + 1);
1112
+ const extension = extname2(path).toLowerCase() || "(none)";
1113
+ const bucket = perExtension.get(extension) ?? { cards: 0, files: /* @__PURE__ */ new Set() };
1114
+ bucket.cards += 1;
1115
+ bucket.files.add(path);
1116
+ perExtension.set(extension, bucket);
1117
+ const key = `${path}::${card.question}`;
1118
+ questionCounts.set(key, (questionCounts.get(key) ?? 0) + 1);
1119
+ lengths.push(card.answer.length);
1120
+ }
1121
+ lengths.sort((a, b) => a - b);
1122
+ const total = lengths.reduce((sum, length) => sum + length, 0);
1123
+ return {
1124
+ cards: cards.length,
1125
+ files: perFile.size,
1126
+ byExtension: [...perExtension.entries()].map(([extension, bucket]) => ({ extension, cards: bucket.cards, files: bucket.files.size })).sort((a, b) => b.cards - a.cards || a.extension.localeCompare(b.extension)),
1127
+ topFiles: [...perFile.entries()].map(([path, count]) => ({ path, cards: count })).sort((a, b) => b.cards - a.cards || a.path.localeCompare(b.path)).slice(0, 10),
1128
+ answerLength: {
1129
+ min: lengths[0] ?? 0,
1130
+ median: median(lengths),
1131
+ max: lengths[lengths.length - 1] ?? 0,
1132
+ mean: lengths.length > 0 ? Math.round(total / lengths.length) : 0
1133
+ },
1134
+ duplicateQuestions: [...questionCounts.values()].filter((count) => count > 1).length
1135
+ };
1136
+ }
1137
+ function formatReport(report) {
1138
+ const lines = [
1139
+ `Cards: ${report.cards}`,
1140
+ `Files with cards: ${report.files}`,
1141
+ "",
1142
+ "By extension:",
1143
+ ...report.byExtension.map((row) => ` ${row.extension.padEnd(8)} ${String(row.cards).padStart(6)} cards ${String(row.files).padStart(5)} files`),
1144
+ "",
1145
+ "Answer length:",
1146
+ ` min ${report.answerLength.min} median ${report.answerLength.median} mean ${report.answerLength.mean} max ${report.answerLength.max}`,
1147
+ "",
1148
+ "Top files:",
1149
+ ...report.topFiles.map((row) => ` ${String(row.cards).padStart(4)} ${row.path}`)
1150
+ ];
1151
+ if (report.duplicateQuestions > 0) {
1152
+ lines.push("", `Duplicate questions within a file: ${report.duplicateQuestions}`);
1153
+ }
1154
+ return lines.join("\n");
1155
+ }
1156
+ var init_report = __esm({
1157
+ "packages/extraction/dist/report.js"() {
1158
+ "use strict";
1159
+ }
1160
+ });
1161
+
1162
+ // packages/extraction/dist/corpus.js
1163
+ async function reportOnRepository(root, options = {}) {
1164
+ const { cards, rejected } = await new ExtractionService().generateWithRejections(root, options);
1165
+ const lines = [formatReport(summarizeCards(cards))];
1166
+ if (rejected.length > 0) {
1167
+ lines.push("", `Filtered by validation: ${rejected.length}`);
1168
+ for (const row of rejectionSummary(rejected)) {
1169
+ lines.push(` ${String(row.cards).padStart(6)} ${row.reason}`);
1170
+ }
1171
+ }
1172
+ return lines.join("\n");
1173
+ }
1174
+ var invokedDirectly;
1175
+ var init_corpus = __esm({
1176
+ "packages/extraction/dist/corpus.js"() {
1177
+ "use strict";
1178
+ init_report();
1179
+ init_validator();
1180
+ init_workstream();
1181
+ invokedDirectly = process.argv[1]?.endsWith("corpus.ts") || process.argv[1]?.endsWith("corpus.js");
1182
+ if (invokedDirectly) {
1183
+ const root = process.argv[2];
1184
+ const subpath = process.argv[3];
1185
+ if (!root) {
1186
+ console.error("Usage: npm run report --workspace @flashlearn/extraction -- <repository> [subpath]");
1187
+ process.exit(2);
1188
+ }
1189
+ reportOnRepository(root, subpath ? { subpath } : {}).then((output) => console.log(output), (error) => {
1190
+ console.error(error instanceof Error ? error.message : String(error));
1191
+ process.exit(1);
1192
+ });
1193
+ }
1194
+ }
1195
+ });
1196
+
1197
+ // packages/extraction/dist/index.js
1198
+ var init_dist = __esm({
1199
+ "packages/extraction/dist/index.js"() {
1200
+ "use strict";
1201
+ init_workstream();
1202
+ init_endpoint();
1203
+ init_report();
1204
+ init_validator();
1205
+ init_corpus();
1206
+ init_extractor();
1207
+ init_extractors();
1208
+ }
1209
+ });
1210
+
1211
+ // packages/frontend/dist/workstream.js
1212
+ var init_workstream2 = __esm({
1213
+ "packages/frontend/dist/workstream.js"() {
1214
+ "use strict";
1215
+ }
1216
+ });
1217
+
1218
+ // packages/frontend/dist/index.js
1219
+ import { createServer } from "node:http";
1220
+ import { readFile as readFile2, realpath } from "node:fs/promises";
1221
+ import { readFileSync, statSync } from "node:fs";
1222
+ import { extname as extname3, join as join4, normalize, sep as sep4 } from "node:path";
1223
+ import { fileURLToPath } from "node:url";
1224
+ function renderPage() {
1225
+ const path = join4(CLIENT, "index.html");
1226
+ try {
1227
+ const { mtimeMs } = statSync(path);
1228
+ if (shell?.mtimeMs !== mtimeMs)
1229
+ shell = { mtimeMs, html: readFileSync(path, "utf8") };
1230
+ return shell.html;
1231
+ } catch {
1232
+ return MISSING;
1233
+ }
1234
+ }
1235
+ function json(response, status, value, body = true) {
1236
+ response.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
1237
+ response.end(body ? JSON.stringify(value) : void 0);
1238
+ }
1239
+ async function readJson(request) {
1240
+ const chunks = [];
1241
+ let size = 0;
1242
+ for await (const chunk of request) {
1243
+ size += chunk.length;
1244
+ if (size > MAX_BODY)
1245
+ return void 0;
1246
+ chunks.push(Buffer.from(chunk));
1247
+ }
1248
+ try {
1249
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
1250
+ } catch {
1251
+ return void 0;
1252
+ }
1253
+ }
1254
+ async function serveClient(response, pathname, body) {
1255
+ let decoded;
1256
+ try {
1257
+ decoded = decodeURIComponent(pathname);
1258
+ } catch {
1259
+ decoded = pathname;
1260
+ }
1261
+ const relative2 = normalize(decoded).replace(/^([/\\]|\.\.)+/, "");
1262
+ const extension = extname3(relative2);
1263
+ if (relative2 && relative2 !== "index.html") {
1264
+ const file = await readContained(relative2);
1265
+ if (file) {
1266
+ response.writeHead(200, { "content-type": TYPES[extension] ?? "application/octet-stream" });
1267
+ return void response.end(body ? file : void 0);
1268
+ }
1269
+ if (extension)
1270
+ return json(response, 404, { error: "Not found" }, body);
1271
+ }
1272
+ const html = renderPage();
1273
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
1274
+ response.end(body ? html : void 0);
1275
+ }
1276
+ async function readContained(relative2) {
1277
+ try {
1278
+ const target = await realpath(join4(CLIENT, relative2));
1279
+ const root = await realpath(CLIENT);
1280
+ if (target !== root && !target.startsWith(root + sep4))
1281
+ return null;
1282
+ return await readFile2(target);
1283
+ } catch {
1284
+ return null;
1285
+ }
1286
+ }
1287
+ function createFlashLearnServer(services) {
1288
+ return createServer(async (request, response) => {
1289
+ try {
1290
+ const url = new URL(request.url ?? "/", "http://localhost");
1291
+ const body = request.method !== "HEAD";
1292
+ const method = request.method === "HEAD" ? "GET" : request.method;
1293
+ if (method === "GET" && url.pathname === "/api/cards")
1294
+ return json(response, 200, await services.listCards(), body);
1295
+ if (method === "GET" && url.pathname === "/api/cards/next") {
1296
+ const card = await services.nextCard();
1297
+ if (!card)
1298
+ return json(response, 404, { error: "No card is due" }, body);
1299
+ const preview = { id: card.id, question: card.question, source: card.source };
1300
+ return json(response, 200, preview, body);
1301
+ }
1302
+ const cardMatch = url.pathname.match(/^\/api\/cards\/([^/]+)$/);
1303
+ if (method === "GET" && cardMatch?.[1]) {
1304
+ const card = await services.getCard(decodeURIComponent(cardMatch[1]));
1305
+ return card ? json(response, 200, card, body) : json(response, 404, { error: "Card not found" }, body);
1306
+ }
1307
+ if (method === "POST" && url.pathname === "/api/review") {
1308
+ const input = await readJson(request);
1309
+ const review = input;
1310
+ const cardId = typeof review?.cardId === "string" ? review.cardId : null;
1311
+ const result = RESULTS.find((value) => value === review?.result);
1312
+ if (!cardId || !result)
1313
+ return json(response, 400, { error: "Invalid review" }, body);
1314
+ if (!await services.getCard(cardId))
1315
+ return json(response, 404, { error: "Card not found" }, body);
1316
+ return json(response, 200, await services.submitReview(cardId, result), body);
1317
+ }
1318
+ if (url.pathname === "/api" || url.pathname.startsWith("/api/"))
1319
+ return json(response, 404, { error: "Not found" }, body);
1320
+ if (method !== "GET")
1321
+ return json(response, 405, { error: "Method not allowed" }, body);
1322
+ return await serveClient(response, url.pathname, body);
1323
+ } catch (error) {
1324
+ return json(response, 500, { error: error instanceof Error ? error.message : "Unknown error" });
1325
+ }
1326
+ });
1327
+ }
1328
+ var CLIENT, TYPES, MISSING, shell, MAX_BODY, RESULTS;
1329
+ var init_dist2 = __esm({
1330
+ "packages/frontend/dist/index.js"() {
1331
+ "use strict";
1332
+ init_workstream2();
1333
+ CLIENT = fileURLToPath(new URL("../client/dist/", import.meta.url));
1334
+ TYPES = {
1335
+ ".css": "text/css; charset=utf-8",
1336
+ ".html": "text/html; charset=utf-8",
1337
+ ".js": "text/javascript; charset=utf-8",
1338
+ ".json": "application/json",
1339
+ ".png": "image/png",
1340
+ ".svg": "image/svg+xml"
1341
+ };
1342
+ MISSING = `<!doctype html><meta charset="utf-8"><title>FlashLearn</title>
1343
+ <body style="font:16px system-ui;max-width:34rem;margin:14vh auto;padding:1rem;background:#f4f1e8;color:#17251d">
1344
+ <h1>Client not built</h1><p>Run <code>npm run build --workspace @flashlearn/frontend</code>, then reload.</p>`;
1345
+ MAX_BODY = 64 * 1024;
1346
+ RESULTS = ["easy", "hard", "correct", "incorrect"];
1347
+ }
1348
+ });
1349
+
1350
+ // packages/learning/dist/workstream.js
1351
+ function compareTimestamps(left, right) {
1352
+ return new Date(left).getTime() - new Date(right).getTime();
1353
+ }
1354
+ var MINIMUM_EASE_FACTOR, INITIAL_EASE_FACTOR, LearningService;
1355
+ var init_workstream3 = __esm({
1356
+ "packages/learning/dist/workstream.js"() {
1357
+ "use strict";
1358
+ MINIMUM_EASE_FACTOR = 1.3;
1359
+ INITIAL_EASE_FACTOR = 2.5;
1360
+ LearningService = class {
1361
+ createReviewState(cardId) {
1362
+ return {
1363
+ cardId,
1364
+ easeFactor: INITIAL_EASE_FACTOR,
1365
+ intervalDays: 0,
1366
+ reviewCount: 0,
1367
+ correctCount: 0
1368
+ };
1369
+ }
1370
+ scheduleReview(state, result, now = /* @__PURE__ */ new Date()) {
1371
+ const successful = result !== "incorrect";
1372
+ const multipliers = {
1373
+ incorrect: 0,
1374
+ hard: 1.2,
1375
+ correct: state.correctCount === 0 ? 1 : state.easeFactor,
1376
+ easy: state.correctCount === 0 ? 4 : state.easeFactor + 0.5
1377
+ };
1378
+ const intervalDays = successful ? Math.max(1, Math.round(Math.max(1, state.intervalDays) * multipliers[result])) : 0;
1379
+ const easeDelta = result === "easy" ? 0.15 : result === "hard" ? -0.15 : result === "incorrect" ? -0.2 : 0;
1380
+ const nextReview = new Date(now);
1381
+ nextReview.setUTCDate(nextReview.getUTCDate() + intervalDays);
1382
+ return {
1383
+ ...state,
1384
+ easeFactor: Math.max(MINIMUM_EASE_FACTOR, state.easeFactor + easeDelta),
1385
+ intervalDays,
1386
+ lastReviewed: now.toISOString(),
1387
+ nextReview: nextReview.toISOString(),
1388
+ reviewCount: state.reviewCount + 1,
1389
+ correctCount: state.correctCount + (successful ? 1 : 0)
1390
+ };
1391
+ }
1392
+ selectNextCard(cards, states, now = /* @__PURE__ */ new Date()) {
1393
+ const byCard = new Map(states.map((state) => [state.cardId, state]));
1394
+ return cards.filter((card) => {
1395
+ const nextReview = byCard.get(card.id)?.nextReview;
1396
+ return !nextReview || new Date(nextReview) <= now;
1397
+ }).sort((left, right) => {
1398
+ const leftDue = byCard.get(left.id)?.nextReview;
1399
+ const rightDue = byCard.get(right.id)?.nextReview;
1400
+ if (leftDue && rightDue) {
1401
+ return compareTimestamps(leftDue, rightDue) || compareTimestamps(left.createdAt, right.createdAt);
1402
+ }
1403
+ if (leftDue)
1404
+ return -1;
1405
+ if (rightDue)
1406
+ return 1;
1407
+ return compareTimestamps(left.createdAt, right.createdAt);
1408
+ })[0] ?? null;
1409
+ }
1410
+ };
1411
+ }
1412
+ });
1413
+
1414
+ // packages/learning/dist/index.js
1415
+ function scheduleReview(state, result, now = /* @__PURE__ */ new Date()) {
1416
+ return learningService.scheduleReview(state, result, now);
1417
+ }
1418
+ function selectNextCard(cards, states, now = /* @__PURE__ */ new Date()) {
1419
+ return learningService.selectNextCard(cards, states, now);
1420
+ }
1421
+ var learningService;
1422
+ var init_dist3 = __esm({
1423
+ "packages/learning/dist/index.js"() {
1424
+ "use strict";
1425
+ init_workstream3();
1426
+ init_workstream3();
1427
+ learningService = new LearningService();
1428
+ }
1429
+ });
1430
+
1431
+ // packages/storage/dist/paths.js
1432
+ import { join as join5, resolve as resolve3 } from "node:path";
1433
+ function storeRoot(root) {
1434
+ return join5(resolve3(root), ".flashlearn");
1435
+ }
1436
+ function cardsPath(root) {
1437
+ return join5(storeRoot(root), "cards.json");
1438
+ }
1439
+ function reviewPath(root) {
1440
+ return join5(storeRoot(root), "review.json");
1441
+ }
1442
+ function settingsPath(root) {
1443
+ return join5(storeRoot(root), "settings.json");
1444
+ }
1445
+ var init_paths2 = __esm({
1446
+ "packages/storage/dist/paths.js"() {
1447
+ "use strict";
1448
+ }
1449
+ });
1450
+
1451
+ // packages/storage/dist/json.js
1452
+ import { randomUUID } from "node:crypto";
1453
+ import { mkdir, readFile as readFile3, rename, writeFile } from "node:fs/promises";
1454
+ import { dirname } from "node:path";
1455
+ function serialize(path, task) {
1456
+ const next = (chains.get(path) ?? Promise.resolve()).then(task, task);
1457
+ chains.set(path, next.catch(() => {
1458
+ }));
1459
+ return next;
1460
+ }
1461
+ async function readJson2(path, fallback) {
1462
+ let raw;
1463
+ try {
1464
+ raw = await readFile3(path, "utf8");
1465
+ } catch (error) {
1466
+ if (error.code === "ENOENT")
1467
+ return fallback;
1468
+ throw error;
1469
+ }
1470
+ try {
1471
+ return JSON.parse(raw);
1472
+ } catch {
1473
+ throw new Error(`Corrupt JSON in ${path}`);
1474
+ }
1475
+ }
1476
+ function updateJson(path, mutate) {
1477
+ return serialize(path, async () => {
1478
+ const current = await readJson2(path, void 0);
1479
+ await atomicWrite(path, mutate(current));
1480
+ });
1481
+ }
1482
+ async function atomicWrite(path, value) {
1483
+ await mkdir(dirname(path), { recursive: true });
1484
+ const temporaryPath = `${path}.${randomUUID()}.tmp`;
1485
+ await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}
1486
+ `);
1487
+ await rename(temporaryPath, path);
1488
+ }
1489
+ var chains;
1490
+ var init_json = __esm({
1491
+ "packages/storage/dist/json.js"() {
1492
+ "use strict";
1493
+ chains = /* @__PURE__ */ new Map();
1494
+ }
1495
+ });
1496
+
1497
+ // packages/storage/dist/validate.js
1498
+ function isRecord(value) {
1499
+ return typeof value === "object" && value !== null;
1500
+ }
1501
+ function isCard(value) {
1502
+ if (!isRecord(value))
1503
+ return false;
1504
+ const source = value.source;
1505
+ return typeof value.id === "string" && typeof value.question === "string" && typeof value.answer === "string" && typeof value.createdAt === "string" && typeof value.updatedAt === "string" && isRecord(source) && typeof source.path === "string" && typeof source.sha === "string" && (value.tags === void 0 || isStringArray(value.tags));
1506
+ }
1507
+ function isReviewState(value) {
1508
+ if (!isRecord(value))
1509
+ return false;
1510
+ return typeof value.cardId === "string" && typeof value.easeFactor === "number" && typeof value.intervalDays === "number" && typeof value.reviewCount === "number" && typeof value.correctCount === "number" && (value.lastReviewed === void 0 || typeof value.lastReviewed === "string") && (value.nextReview === void 0 || typeof value.nextReview === "string");
1511
+ }
1512
+ function isSafeKey(key) {
1513
+ return key !== "__proto__" && key !== "constructor" && key !== "prototype";
1514
+ }
1515
+ function isStringArray(value) {
1516
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
1517
+ }
1518
+ var init_validate = __esm({
1519
+ "packages/storage/dist/validate.js"() {
1520
+ "use strict";
1521
+ }
1522
+ });
1523
+
1524
+ // packages/storage/dist/repositories.js
1525
+ function parseCards2(value) {
1526
+ return Array.isArray(value) ? value.filter(isCard) : [];
1527
+ }
1528
+ function parseStates(value) {
1529
+ const states = /* @__PURE__ */ Object.create(null);
1530
+ if (typeof value === "object" && value !== null) {
1531
+ for (const [key, state] of Object.entries(value)) {
1532
+ if (isSafeKey(key) && isReviewState(state))
1533
+ states[key] = state;
1534
+ }
1535
+ }
1536
+ return states;
1537
+ }
1538
+ var DEFAULT_REVIEW_STATE, JsonCardRepository, JsonReviewRepository;
1539
+ var init_repositories = __esm({
1540
+ "packages/storage/dist/repositories.js"() {
1541
+ "use strict";
1542
+ init_json();
1543
+ init_validate();
1544
+ DEFAULT_REVIEW_STATE = (cardId) => ({
1545
+ cardId,
1546
+ easeFactor: 2.5,
1547
+ intervalDays: 0,
1548
+ reviewCount: 0,
1549
+ correctCount: 0
1550
+ });
1551
+ JsonCardRepository = class {
1552
+ path;
1553
+ constructor(path) {
1554
+ this.path = path;
1555
+ }
1556
+ async save(card) {
1557
+ await updateJson(this.path, (current) => {
1558
+ const cards = parseCards2(current);
1559
+ const index = cards.findIndex(({ id }) => id === card.id);
1560
+ if (index === -1)
1561
+ cards.push(card);
1562
+ else
1563
+ cards[index] = card;
1564
+ return cards;
1565
+ });
1566
+ }
1567
+ async get(id) {
1568
+ return (await this.list()).find((card) => card.id === id) ?? null;
1569
+ }
1570
+ async list() {
1571
+ return parseCards2(await readJson2(this.path, []));
1572
+ }
1573
+ async delete(id) {
1574
+ await updateJson(this.path, (current) => parseCards2(current).filter((card) => card.id !== id));
1575
+ }
1576
+ };
1577
+ JsonReviewRepository = class {
1578
+ path;
1579
+ constructor(path) {
1580
+ this.path = path;
1581
+ }
1582
+ async get(cardId) {
1583
+ const states = parseStates(await readJson2(this.path, {}));
1584
+ return states[cardId] ?? DEFAULT_REVIEW_STATE(cardId);
1585
+ }
1586
+ async save(state) {
1587
+ if (!isSafeKey(state.cardId))
1588
+ throw new Error(`Unsafe card ID: ${state.cardId}`);
1589
+ await updateJson(this.path, (current) => {
1590
+ const states = parseStates(current);
1591
+ states[state.cardId] = state;
1592
+ return states;
1593
+ });
1594
+ }
1595
+ };
1596
+ }
1597
+ });
1598
+
1599
+ // packages/storage/dist/workstream.js
1600
+ var init_workstream4 = __esm({
1601
+ "packages/storage/dist/workstream.js"() {
1602
+ "use strict";
1603
+ init_dist4();
1604
+ init_paths2();
1605
+ init_repositories();
1606
+ }
1607
+ });
1608
+
1609
+ // packages/storage/dist/index.js
1610
+ import { mkdir as mkdir2, writeFile as writeFile2 } from "node:fs/promises";
1611
+ async function initializeStore(root) {
1612
+ await mkdir2(storeRoot(root), { recursive: true });
1613
+ const files = [
1614
+ [cardsPath(root), []],
1615
+ [reviewPath(root), {}],
1616
+ [settingsPath(root), { version: 1 }]
1617
+ ];
1618
+ await Promise.all(files.map(async ([path, initial]) => {
1619
+ try {
1620
+ await writeFile2(path, `${JSON.stringify(initial, null, 2)}
1621
+ `, { flag: "wx" });
1622
+ } catch (error) {
1623
+ if (error.code !== "EEXIST")
1624
+ throw error;
1625
+ }
1626
+ }));
1627
+ }
1628
+ var init_dist4 = __esm({
1629
+ "packages/storage/dist/index.js"() {
1630
+ "use strict";
1631
+ init_paths2();
1632
+ init_repositories();
1633
+ init_paths2();
1634
+ init_json();
1635
+ init_validate();
1636
+ init_workstream4();
1637
+ }
1638
+ });
1639
+
1640
+ // packages/cli/src/production.ts
1641
+ import { stat } from "node:fs/promises";
1642
+ import { join as join6 } from "node:path";
1643
+ function createProductionDependencies() {
1644
+ return {
1645
+ initializeStore,
1646
+ generateCards: (root, options) => generateCards(root, void 0, options),
1647
+ createCardRepository: (root) => new JsonCardRepository(join6(flashlearnRoot(root), "cards.json")),
1648
+ createReviewRepository: (root) => new JsonReviewRepository(join6(flashlearnRoot(root), "review.json")),
1649
+ scheduleReview,
1650
+ selectNextCard,
1651
+ createServer: createFlashLearnServer,
1652
+ listenServer: async (handle, host, port) => {
1653
+ const server = handle;
1654
+ await new Promise((resolveListen, reject) => {
1655
+ const onError = (error) => reject(error);
1656
+ server.once("error", onError);
1657
+ server.listen(port, host, () => {
1658
+ server.off("error", onError);
1659
+ resolveListen();
1660
+ });
1661
+ });
1662
+ },
1663
+ isDirectory: async (path) => {
1664
+ try {
1665
+ return (await stat(path)).isDirectory();
1666
+ } catch (error) {
1667
+ if (error.code === "ENOENT") return false;
1668
+ throw error;
1669
+ }
1670
+ },
1671
+ now: () => /* @__PURE__ */ new Date()
1672
+ };
1673
+ }
1674
+ var init_production = __esm({
1675
+ "packages/cli/src/production.ts"() {
1676
+ "use strict";
1677
+ init_dist();
1678
+ init_dist2();
1679
+ init_dist3();
1680
+ init_dist4();
1681
+ init_paths();
1682
+ }
1683
+ });
1684
+
1685
+ // packages/cli/src/workstream.ts
1686
+ import { createHash } from "node:crypto";
1687
+ var pendingReviews, CliService;
1688
+ var init_workstream5 = __esm({
1689
+ "packages/cli/src/workstream.ts"() {
1690
+ "use strict";
1691
+ init_paths();
1692
+ pendingReviews = /* @__PURE__ */ new Map();
1693
+ CliService = class {
1694
+ constructor(dependencies2) {
1695
+ this.dependencies = dependencies2;
1696
+ }
1697
+ async initialize(root) {
1698
+ const rootPath = projectRoot(root);
1699
+ await this.dependencies.initializeStore(rootPath);
1700
+ }
1701
+ async generate(directory, options) {
1702
+ const root = projectRoot(directory);
1703
+ await this.dependencies.initializeStore(root);
1704
+ const repository = this.dependencies.createCardRepository(root);
1705
+ const generatedCards = await this.dependencies.generateCards(root, options);
1706
+ const updatedAt = this.dependencies.now().toISOString();
1707
+ const cards = [];
1708
+ for (const generated of generatedCards) {
1709
+ this.validateGeneratedCard(generated);
1710
+ const id = createHash("sha256").update(`${generated.source.path}\0${generated.question}`).digest("hex").slice(0, 16);
1711
+ const existing = await repository.get(id);
1712
+ const card = {
1713
+ ...generated,
1714
+ id,
1715
+ createdAt: existing?.createdAt ?? updatedAt,
1716
+ updatedAt
1717
+ };
1718
+ await repository.save(card);
1719
+ cards.push(card);
1720
+ }
1721
+ return cards;
1722
+ }
1723
+ async start(root, options = {}) {
1724
+ const rootPath = projectRoot(root);
1725
+ await this.dependencies.initializeStore(rootPath);
1726
+ const cards = this.dependencies.createCardRepository(rootPath);
1727
+ const reviews = this.dependencies.createReviewRepository(rootPath);
1728
+ const services = {
1729
+ listCards: () => cards.list(),
1730
+ getCard: (id) => cards.get(id),
1731
+ nextCard: async () => {
1732
+ const allCards = await cards.list();
1733
+ const states = await Promise.all(allCards.map(({ id }) => reviews.get(id)));
1734
+ return this.dependencies.selectNextCard(allCards, states, this.dependencies.now());
1735
+ },
1736
+ submitReview: (cardId, result) => this.serializeReview(rootPath, cardId, async () => {
1737
+ if (!await cards.get(cardId)) throw new Error(`Card not found: ${cardId}`);
1738
+ const state = this.dependencies.scheduleReview(
1739
+ await reviews.get(cardId),
1740
+ result,
1741
+ this.dependencies.now()
1742
+ );
1743
+ await reviews.save(state);
1744
+ return state;
1745
+ })
1746
+ };
1747
+ const server = this.dependencies.createServer(services);
1748
+ await this.dependencies.listenServer(server, options.host ?? "localhost", options.port ?? 4173);
1749
+ }
1750
+ async resolveProject(directory = ".") {
1751
+ const root = projectRoot(directory);
1752
+ if (!await this.dependencies.isDirectory(root)) throw new Error(`Project directory not found: ${root}`);
1753
+ return root;
1754
+ }
1755
+ async getCard(id, directory) {
1756
+ const root = await this.resolveProject(directory);
1757
+ return this.dependencies.createCardRepository(root).get(id);
1758
+ }
1759
+ async listCards(directory) {
1760
+ const root = await this.resolveProject(directory);
1761
+ return this.dependencies.createCardRepository(root).list();
1762
+ }
1763
+ async status(directory) {
1764
+ const root = await this.resolveProject(directory);
1765
+ const cards = await this.dependencies.createCardRepository(root).list();
1766
+ const reviews = this.dependencies.createReviewRepository(root);
1767
+ const states = await Promise.all(cards.map(({ id }) => reviews.get(id)));
1768
+ const now = this.dependencies.now();
1769
+ const reviewed = states.filter(({ reviewCount }) => reviewCount > 0).length;
1770
+ const due = states.filter(({ nextReview }) => !nextReview || new Date(nextReview) <= now).length;
1771
+ return { project: root, cards: cards.length, reviewed, unreviewed: cards.length - reviewed, due };
1772
+ }
1773
+ validateGeneratedCard(card) {
1774
+ if (typeof card.question !== "string" || typeof card.answer !== "string" || typeof card.source?.path !== "string" || typeof card.source.sha !== "string") {
1775
+ throw new Error("Extraction returned an invalid GeneratedCard");
1776
+ }
1777
+ }
1778
+ serializeReview(root, cardId, operation) {
1779
+ const key = JSON.stringify([root, cardId]);
1780
+ const result = (pendingReviews.get(key) ?? Promise.resolve()).then(operation);
1781
+ const settled = result.then(() => {
1782
+ }, () => {
1783
+ });
1784
+ pendingReviews.set(key, settled);
1785
+ void settled.then(() => {
1786
+ if (pendingReviews.get(key) === settled) pendingReviews.delete(key);
1787
+ });
1788
+ return result;
1789
+ }
1790
+ };
1791
+ }
1792
+ });
1793
+
1794
+ // packages/cli/src/confirm.ts
1795
+ import { createInterface } from "node:readline/promises";
1796
+ async function confirm(message) {
1797
+ if (!process.stdin.isTTY || !process.stderr.isTTY) return false;
1798
+ const reader = createInterface({ input: process.stdin, output: process.stderr });
1799
+ try {
1800
+ const answer = await reader.question(message);
1801
+ return /^(y|yes)$/i.test(answer.trim());
1802
+ } finally {
1803
+ reader.close();
1804
+ }
1805
+ }
1806
+ var init_confirm = __esm({
1807
+ "packages/cli/src/confirm.ts"() {
1808
+ "use strict";
1809
+ }
1810
+ });
1811
+
1812
+ // packages/cli/src/index.ts
1813
+ var src_exports = {};
1814
+ var dependencies, service;
1815
+ var init_src = __esm({
1816
+ async "packages/cli/src/index.ts"() {
1817
+ "use strict";
1818
+ init_cli();
1819
+ init_production();
1820
+ init_workstream5();
1821
+ init_confirm();
1822
+ dependencies = createProductionDependencies();
1823
+ service = new CliService(dependencies);
1824
+ process.exitCode = await runCli(process.argv.slice(2), service, {
1825
+ cwd: process.cwd(),
1826
+ stdout: (message) => console.log(message),
1827
+ stderr: (message) => console.error(message),
1828
+ confirm
1829
+ });
1830
+ }
1831
+ });
1832
+
1833
+ // release/index.mjs
1834
+ import { readFileSync as readFileSync2 } from "node:fs";
1835
+ var args = process.argv.slice(2);
1836
+ if (args.length === 1 && ["--version", "-v"].includes(args[0])) {
1837
+ console.log(JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf8")).version);
1838
+ } else {
1839
+ await init_src().then(() => src_exports);
1840
+ }