@wyattjoh/demur 0.4.2 → 0.5.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/src/cli.ts CHANGED
@@ -1,5 +1,15 @@
1
1
  #!/usr/bin/env bun
2
+ import { createInterface } from "node:readline/promises";
2
3
  import { Predicate } from "effect";
4
+ import { loadCostTotals } from "../extensions/demur/cost-tracker.ts";
5
+ import {
6
+ loadTrainingRecords,
7
+ loadTrainingReviews,
8
+ recordTrainingReview,
9
+ type TrainingRecord,
10
+ type TrainingReview,
11
+ type TrainingReviewInput,
12
+ } from "../extensions/demur/training-store.ts";
3
13
  import { guard } from "./guard.ts";
4
14
  import {
5
15
  deleteApiKey,
@@ -7,12 +17,20 @@ import {
7
17
  storeApiKey,
8
18
  type ResolvedApiKey,
9
19
  } from "./key.ts";
20
+ import {
21
+ buildTrainingReviewEntries,
22
+ getTrainingReviewFilter,
23
+ type TrainingReviewSnapshot,
24
+ } from "./training-review-model.ts";
25
+ import type { TrainingReviewTuiResult } from "./training-review-tui.tsx";
10
26
  import type { Verdict } from "./types.ts";
11
27
 
12
28
  const USAGE = `Usage:
29
+ demur
13
30
  demur auth login
14
31
  demur auth status
15
32
  demur auth logout
33
+ demur training review [--plain]
16
34
  demur judge "<command>" [--cwd=<path>]`;
17
35
 
18
36
  /**
@@ -23,18 +41,49 @@ export type CliDependencies = {
23
41
  resolveApiKey(): Promise<ResolvedApiKey | undefined>;
24
42
  storeApiKey(value: string): Promise<void>;
25
43
  deleteApiKey(): Promise<boolean>;
44
+ loadTrainingRecords(): Promise<ReadonlyArray<TrainingRecord>>;
45
+ loadTrainingReviews(): Promise<ReadonlyArray<TrainingReview>>;
46
+ loadGlobalEstimatedCostUsd(): Promise<number>;
47
+ recordTrainingReview(input: TrainingReviewInput): Promise<TrainingReview>;
48
+ runTrainingReviewTui(
49
+ snapshot: TrainingReviewSnapshot,
50
+ reloadSnapshot: () => Promise<TrainingReviewSnapshot>,
51
+ recordReview: (input: TrainingReviewInput) => Promise<TrainingReview>,
52
+ ): Promise<TrainingReviewTuiResult>;
53
+ isInteractive(): boolean;
26
54
  readSecret(prompt: string): Promise<string>;
55
+ readLine(prompt: string): Promise<string>;
27
56
  cwd(): string;
28
57
  stdout(message: string): void;
29
58
  stderr(message: string): void;
30
59
  };
31
60
 
61
+ let lineInput:
62
+ | {
63
+ terminal: ReturnType<typeof createInterface>;
64
+ lines: AsyncIterator<string>;
65
+ }
66
+ | undefined;
67
+
32
68
  const defaultDependencies: CliDependencies = {
33
69
  judge: (command, cwd) => guard(command, cwd, "cli"),
34
70
  resolveApiKey,
35
71
  storeApiKey,
36
72
  deleteApiKey,
73
+ loadTrainingRecords,
74
+ loadTrainingReviews,
75
+ loadGlobalEstimatedCostUsd: async () =>
76
+ (await loadCostTotals()).estimatedCostUsd,
77
+ recordTrainingReview,
78
+ runTrainingReviewTui: async (snapshot, reloadSnapshot, recordReview) => {
79
+ const { runTrainingReviewTui } = await import(
80
+ "./training-review-tui.tsx"
81
+ );
82
+ return runTrainingReviewTui(snapshot, reloadSnapshot, recordReview);
83
+ },
84
+ isInteractive: () => Boolean(process.stdin.isTTY && process.stdout.isTTY),
37
85
  readSecret,
86
+ readLine,
38
87
  cwd: () => process.cwd(),
39
88
  stdout: (message) => console.log(message),
40
89
  stderr: (message) => console.error(message),
@@ -52,10 +101,24 @@ export async function runCli(
52
101
  dependencies: CliDependencies = defaultDependencies,
53
102
  ): Promise<number> {
54
103
  try {
104
+ if (args.length === 0) {
105
+ if (!dependencies.isInteractive()) {
106
+ dependencies.stderr(
107
+ "demur: the interactive app requires a terminal; use `demur training review --plain` for line-oriented review.",
108
+ );
109
+ return 2;
110
+ }
111
+ return await runTraining(["review"], dependencies);
112
+ }
113
+
55
114
  if (args[0] === "auth") {
56
115
  return await runAuth(args.slice(1), dependencies);
57
116
  }
58
117
 
118
+ if (args[0] === "training") {
119
+ return await runTraining(args.slice(1), dependencies);
120
+ }
121
+
59
122
  if (args[0] === "help" || args[0] === "--help" || args[0] === "-h") {
60
123
  dependencies.stdout(USAGE);
61
124
  return 0;
@@ -66,6 +129,8 @@ export async function runCli(
66
129
  } catch (error: unknown) {
67
130
  dependencies.stderr(`demur: ${errorDetail(error)}`);
68
131
  return 1;
132
+ } finally {
133
+ closeLineInput();
69
134
  }
70
135
  }
71
136
 
@@ -136,6 +201,158 @@ async function runAuth(
136
201
  return 2;
137
202
  }
138
203
 
204
+ async function runTraining(
205
+ args: ReadonlyArray<string>,
206
+ dependencies: CliDependencies,
207
+ ): Promise<number> {
208
+ const plain = args.includes("--plain");
209
+ const operands = args.filter((arg) => arg !== "--plain");
210
+ if (
211
+ operands.length !== 1 ||
212
+ operands[0] !== "review" ||
213
+ args.filter((arg) => arg === "--plain").length > 1
214
+ ) {
215
+ dependencies.stderr(USAGE);
216
+ return 2;
217
+ }
218
+
219
+ const loadSnapshot = () => loadTrainingReviewSnapshot(dependencies);
220
+ const snapshot = await loadSnapshot();
221
+
222
+ if (!plain && dependencies.isInteractive()) {
223
+ const result = await dependencies.runTrainingReviewTui(
224
+ snapshot,
225
+ loadSnapshot,
226
+ dependencies.recordTrainingReview,
227
+ );
228
+ printReviewSummary(result, dependencies);
229
+ return 0;
230
+ }
231
+
232
+ const pending = buildTrainingReviewEntries(snapshot)
233
+ .filter((entry) => getTrainingReviewFilter(entry) === "unreviewed")
234
+ .map((entry) => entry.record);
235
+ if (pending.length === 0) {
236
+ dependencies.stdout("No unreviewed demur training records.");
237
+ return 0;
238
+ }
239
+
240
+ return runPlainTrainingReview(pending, dependencies);
241
+ }
242
+
243
+ async function loadTrainingReviewSnapshot(
244
+ dependencies: CliDependencies,
245
+ ): Promise<TrainingReviewSnapshot> {
246
+ const [records, reviews, globalEstimatedCostUsd] = await Promise.all([
247
+ dependencies.loadTrainingRecords(),
248
+ dependencies.loadTrainingReviews(),
249
+ dependencies.loadGlobalEstimatedCostUsd(),
250
+ ]);
251
+ return { records, reviews, globalEstimatedCostUsd };
252
+ }
253
+
254
+ async function runPlainTrainingReview(
255
+ pending: ReadonlyArray<TrainingRecord>,
256
+ dependencies: CliDependencies,
257
+ ): Promise<number> {
258
+ let reviewed = 0;
259
+ let corrected = 0;
260
+ let skipped = 0;
261
+ for (const [index, record] of pending.entries()) {
262
+ printTrainingRecord(record, index + 1, pending.length, dependencies);
263
+ const expectedDecision = await readExpectedDecision(record, dependencies);
264
+ if (expectedDecision === "quit") break;
265
+ if (expectedDecision === "skip") {
266
+ skipped += 1;
267
+ continue;
268
+ }
269
+
270
+ const isCorrection = expectedDecision !== record.verdict.decision;
271
+ const note = isCorrection
272
+ ? (await dependencies.readLine("Correction note (optional): ")).trim() ||
273
+ undefined
274
+ : undefined;
275
+ await dependencies.recordTrainingReview({
276
+ recordId: record.id,
277
+ originalDecision: record.verdict.decision,
278
+ expectedDecision,
279
+ note,
280
+ });
281
+ reviewed += 1;
282
+ if (isCorrection) corrected += 1;
283
+ dependencies.stdout(
284
+ isCorrection
285
+ ? `Recorded correction: ${record.verdict.decision} → ${expectedDecision}.`
286
+ : `Accepted ${record.verdict.decision} decision.`,
287
+ );
288
+ }
289
+
290
+ printReviewSummary({ reviewed, corrected, skipped }, dependencies);
291
+ return 0;
292
+ }
293
+
294
+ function printReviewSummary(
295
+ result: TrainingReviewTuiResult,
296
+ dependencies: CliDependencies,
297
+ ): void {
298
+ const skipped = result.skipped === 0
299
+ ? "."
300
+ : `; ${result.skipped} skipped.`;
301
+ dependencies.stdout(
302
+ `Reviewed ${result.reviewed} record${result.reviewed === 1 ? "" : "s"}; ${result.corrected} corrected${skipped}`,
303
+ );
304
+ }
305
+
306
+ async function readExpectedDecision(
307
+ record: TrainingRecord,
308
+ dependencies: CliDependencies,
309
+ ): Promise<"allow" | "ask" | "deny" | "skip" | "quit"> {
310
+ while (true) {
311
+ const input = (
312
+ await dependencies.readLine(
313
+ `Expected decision [enter=${record.verdict.decision}, allow, ask, deny, skip, quit]: `,
314
+ )
315
+ ).trim().toLowerCase();
316
+
317
+ if (input === "") return record.verdict.decision;
318
+ if (
319
+ input === "allow" ||
320
+ input === "ask" ||
321
+ input === "deny" ||
322
+ input === "skip" ||
323
+ input === "quit"
324
+ ) {
325
+ return input;
326
+ }
327
+ dependencies.stderr(`Unknown review choice: ${input}`);
328
+ }
329
+ }
330
+
331
+ function printTrainingRecord(
332
+ record: TrainingRecord,
333
+ index: number,
334
+ total: number,
335
+ dependencies: CliDependencies,
336
+ ): void {
337
+ dependencies.stdout("");
338
+ dependencies.stdout(
339
+ `[${index}/${total}] ${record.recordedAt} · ${record.mode} · host ${record.hostAction}`,
340
+ );
341
+ dependencies.stdout(`cwd: ${record.cwd}`);
342
+ dependencies.stdout(`command: ${record.command}`);
343
+ dependencies.stdout(
344
+ `verdict: ${record.verdict.decision.toUpperCase()} — ${record.verdict.reason}`,
345
+ );
346
+ if (record.verdict.judgments !== undefined) {
347
+ dependencies.stdout(
348
+ `judgments: ${JSON.stringify(record.verdict.judgments)}`,
349
+ );
350
+ }
351
+ if (record.verdict.failure !== undefined) {
352
+ dependencies.stdout(`failure: ${record.verdict.failure}`);
353
+ }
354
+ }
355
+
139
356
  async function runJudge(
140
357
  args: ReadonlyArray<string>,
141
358
  dependencies: CliDependencies,
@@ -186,6 +403,26 @@ async function runJudge(
186
403
  return 0;
187
404
  }
188
405
 
406
+ async function readLine(prompt: string): Promise<string> {
407
+ if (lineInput === undefined) {
408
+ const terminal = createInterface({ input: process.stdin });
409
+ lineInput = {
410
+ terminal,
411
+ lines: terminal[Symbol.asyncIterator](),
412
+ };
413
+ }
414
+
415
+ process.stderr.write(prompt);
416
+ const next = await lineInput.lines.next();
417
+ if (next.done) throw new Error("training review input ended");
418
+ return next.value;
419
+ }
420
+
421
+ function closeLineInput(): void {
422
+ lineInput?.terminal.close();
423
+ lineInput = undefined;
424
+ }
425
+
189
426
  async function readSecret(prompt: string): Promise<string> {
190
427
  if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== "function") {
191
428
  return (await Bun.stdin.text()).trim();
@@ -0,0 +1,176 @@
1
+ import type {
2
+ TrainingRecord,
3
+ TrainingReview,
4
+ TrainingReviewInput,
5
+ } from "../extensions/demur/training-store.ts";
6
+ import type { Decision } from "./types.ts";
7
+
8
+ /**
9
+ * Complete append-only training evidence and human review state.
10
+ */
11
+ export type TrainingReviewSnapshot = {
12
+ records: ReadonlyArray<TrainingRecord>;
13
+ reviews: ReadonlyArray<TrainingReview>;
14
+ globalEstimatedCostUsd: number;
15
+ };
16
+
17
+ /**
18
+ * One training record paired with every review appended for its stable ID.
19
+ */
20
+ export type TrainingReviewEntry = {
21
+ record: TrainingRecord;
22
+ reviews: ReadonlyArray<TrainingReview>;
23
+ };
24
+
25
+ /**
26
+ * Latest human-review status derived for one training entry.
27
+ */
28
+ export type TrainingReviewStatus = "unreviewed" | Decision;
29
+
30
+ /**
31
+ * Status tabs available in the interactive training-review queue.
32
+ */
33
+ export type TrainingReviewFilter = "all" | TrainingReviewStatus;
34
+
35
+ /**
36
+ * Pair each captured record with its append-only review history.
37
+ *
38
+ * @param snapshot - Complete record and review state
39
+ * @returns Entries in capture order with reviews in append order
40
+ */
41
+ export function buildTrainingReviewEntries(
42
+ snapshot: TrainingReviewSnapshot,
43
+ ): ReadonlyArray<TrainingReviewEntry> {
44
+ const reviewsByRecord = new Map<string, Array<TrainingReview>>();
45
+ for (const review of snapshot.reviews) {
46
+ const reviews = reviewsByRecord.get(review.recordId) ?? [];
47
+ reviews.push(review);
48
+ reviewsByRecord.set(review.recordId, reviews);
49
+ }
50
+
51
+ return snapshot.records.map((record) => ({
52
+ record,
53
+ reviews: reviewsByRecord.get(record.id) ?? [],
54
+ }));
55
+ }
56
+
57
+ /**
58
+ * Return the latest append-only human review for an entry.
59
+ *
60
+ * @param entry - Training record and its review history
61
+ * @returns Latest review, or undefined when the record is unreviewed
62
+ */
63
+ export function getLatestTrainingReview(
64
+ entry: TrainingReviewEntry,
65
+ ): TrainingReview | undefined {
66
+ return entry.reviews.at(-1);
67
+ }
68
+
69
+ /**
70
+ * Classify an entry by its latest human answer.
71
+ *
72
+ * @param entry - Training record and its review history
73
+ * @returns Unreviewed or the latest expected decision
74
+ */
75
+ export function getTrainingReviewFilter(
76
+ entry: TrainingReviewEntry,
77
+ ): TrainingReviewStatus {
78
+ return getLatestTrainingReview(entry)?.expectedDecision ?? "unreviewed";
79
+ }
80
+
81
+ /**
82
+ * Select one status tab and fuzzy-search matching working directories.
83
+ *
84
+ * @param entries - Complete training history
85
+ * @param filter - Active latest-answer status
86
+ * @param cwdQuery - Optional fuzzy working-directory query
87
+ * @returns Matching entries, ranked by path match when a query is present
88
+ */
89
+ export function filterTrainingReviewEntries(
90
+ entries: ReadonlyArray<TrainingReviewEntry>,
91
+ filter: TrainingReviewFilter,
92
+ cwdQuery: string,
93
+ ): ReadonlyArray<TrainingReviewEntry> {
94
+ const statusMatches = filter === "all"
95
+ ? entries
96
+ : entries.filter((entry) => getTrainingReviewFilter(entry) === filter);
97
+ const query = cwdQuery.trim().toLowerCase();
98
+ if (query === "") return statusMatches;
99
+
100
+ return statusMatches
101
+ .map((entry, index) => ({
102
+ entry,
103
+ index,
104
+ score: fuzzyPathScore(entry.record.cwd, query),
105
+ }))
106
+ .filter(
107
+ (candidate): candidate is {
108
+ entry: TrainingReviewEntry;
109
+ index: number;
110
+ score: number;
111
+ } => candidate.score !== undefined,
112
+ )
113
+ .sort((left, right) => right.score - left.score || left.index - right.index)
114
+ .map((candidate) => candidate.entry);
115
+ }
116
+
117
+ /**
118
+ * Build an append-only review revision from a human decision.
119
+ *
120
+ * @param record - Training evidence being reviewed
121
+ * @param expectedDecision - Human-selected expected outcome
122
+ * @param note - Optional correction explanation
123
+ * @returns Normalized review input for persistence
124
+ */
125
+ export function createTrainingReviewInput(
126
+ record: TrainingRecord,
127
+ expectedDecision: Decision,
128
+ note: string | undefined,
129
+ ): TrainingReviewInput {
130
+ const corrected = expectedDecision !== record.verdict.decision;
131
+ return {
132
+ recordId: record.id,
133
+ originalDecision: record.verdict.decision,
134
+ expectedDecision,
135
+ note: corrected ? note?.trim() || undefined : undefined,
136
+ };
137
+ }
138
+
139
+ function fuzzyPathScore(path: string, query: string): number | undefined {
140
+ const candidate = path.toLowerCase();
141
+ const substringIndex = candidate.indexOf(query);
142
+ if (substringIndex >= 0) {
143
+ return 10_000 + segmentStartBonus(candidate, substringIndex) -
144
+ substringIndex;
145
+ }
146
+
147
+ const positions: Array<number> = [];
148
+ let candidateIndex = 0;
149
+ for (const character of query) {
150
+ const matchIndex = candidate.indexOf(character, candidateIndex);
151
+ if (matchIndex < 0) return undefined;
152
+ positions.push(matchIndex);
153
+ candidateIndex = matchIndex + 1;
154
+ }
155
+
156
+ const first = positions[0];
157
+ const last = positions.at(-1);
158
+ if (first === undefined || last === undefined) return undefined;
159
+
160
+ const span = last - first + 1;
161
+ let consecutive = 0;
162
+ let segmentStarts = 0;
163
+ for (const [index, position] of positions.entries()) {
164
+ if (index > 0 && position === positions[index - 1]! + 1) {
165
+ consecutive += 1;
166
+ }
167
+ segmentStarts += segmentStartBonus(candidate, position);
168
+ }
169
+
170
+ return query.length * 100 - span * 4 - first + consecutive * 12 +
171
+ segmentStarts;
172
+ }
173
+
174
+ function segmentStartBonus(path: string, index: number): number {
175
+ return index === 0 || path[index - 1] === "/" ? 40 : 0;
176
+ }