@wyattjoh/demur 0.4.2 → 0.6.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/README.md +99 -21
- package/extensions/demur/cost-tracker.ts +31 -6
- package/extensions/demur/index.ts +150 -36
- package/extensions/demur/paths.ts +46 -0
- package/extensions/demur/settings.ts +77 -19
- package/extensions/demur/training-store.ts +334 -0
- package/package.json +12 -3
- package/src/cli.ts +508 -1
- package/src/settings-model.ts +60 -0
- package/src/training-review-model.ts +175 -0
- package/src/training-review-tui.tsx +1231 -0
|
@@ -0,0 +1,175 @@
|
|
|
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 explanation for the human decision
|
|
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
|
+
return {
|
|
131
|
+
recordId: record.id,
|
|
132
|
+
originalDecision: record.verdict.decision,
|
|
133
|
+
expectedDecision,
|
|
134
|
+
note: note?.trim() || undefined,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function fuzzyPathScore(path: string, query: string): number | undefined {
|
|
139
|
+
const candidate = path.toLowerCase();
|
|
140
|
+
const substringIndex = candidate.indexOf(query);
|
|
141
|
+
if (substringIndex >= 0) {
|
|
142
|
+
return 10_000 + segmentStartBonus(candidate, substringIndex) -
|
|
143
|
+
substringIndex;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const positions: Array<number> = [];
|
|
147
|
+
let candidateIndex = 0;
|
|
148
|
+
for (const character of query) {
|
|
149
|
+
const matchIndex = candidate.indexOf(character, candidateIndex);
|
|
150
|
+
if (matchIndex < 0) return undefined;
|
|
151
|
+
positions.push(matchIndex);
|
|
152
|
+
candidateIndex = matchIndex + 1;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const first = positions[0];
|
|
156
|
+
const last = positions.at(-1);
|
|
157
|
+
if (first === undefined || last === undefined) return undefined;
|
|
158
|
+
|
|
159
|
+
const span = last - first + 1;
|
|
160
|
+
let consecutive = 0;
|
|
161
|
+
let segmentStarts = 0;
|
|
162
|
+
for (const [index, position] of positions.entries()) {
|
|
163
|
+
if (index > 0 && position === positions[index - 1]! + 1) {
|
|
164
|
+
consecutive += 1;
|
|
165
|
+
}
|
|
166
|
+
segmentStarts += segmentStartBonus(candidate, position);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return query.length * 100 - span * 4 - first + consecutive * 12 +
|
|
170
|
+
segmentStarts;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function segmentStartBonus(path: string, index: number): number {
|
|
174
|
+
return index === 0 || path[index - 1] === "/" ? 40 : 0;
|
|
175
|
+
}
|