pi-fluency 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/LICENSE +21 -0
- package/README.md +171 -0
- package/extensions/pi-fluency/analytics.ts +253 -0
- package/extensions/pi-fluency/analyzer.ts +171 -0
- package/extensions/pi-fluency/collector.ts +102 -0
- package/extensions/pi-fluency/context.ts +65 -0
- package/extensions/pi-fluency/diff.ts +147 -0
- package/extensions/pi-fluency/generation-marker.ts +51 -0
- package/extensions/pi-fluency/history-codec.ts +266 -0
- package/extensions/pi-fluency/index.ts +459 -0
- package/extensions/pi-fluency/overlay.ts +637 -0
- package/extensions/pi-fluency/retention.ts +48 -0
- package/extensions/pi-fluency/sanitize.ts +40 -0
- package/extensions/pi-fluency/setup.ts +29 -0
- package/extensions/pi-fluency/state-reducer.ts +192 -0
- package/extensions/pi-fluency/status.ts +39 -0
- package/extensions/pi-fluency/store.ts +589 -0
- package/extensions/pi-fluency/taxonomy.ts +73 -0
- package/extensions/pi-fluency/types.ts +138 -0
- package/extensions/pi-fluency/worker.ts +144 -0
- package/package.json +63 -0
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { appendFile, chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { lock, type LockOptions } from "proper-lockfile";
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_SETTINGS,
|
|
7
|
+
HISTORY_SCHEMA_VERSION,
|
|
8
|
+
SCHEMA_VERSION,
|
|
9
|
+
type AnalysisResult,
|
|
10
|
+
type CollectedPrompt,
|
|
11
|
+
type FluencyAnalyticsSnapshot,
|
|
12
|
+
type FluencyEvent,
|
|
13
|
+
type FluencySettings,
|
|
14
|
+
type FluencyState,
|
|
15
|
+
type MistakePattern,
|
|
16
|
+
type ReviewPattern,
|
|
17
|
+
} from "./types.js";
|
|
18
|
+
import {
|
|
19
|
+
ERRANT_CATEGORIES,
|
|
20
|
+
errantCategory,
|
|
21
|
+
type ErrantCategory,
|
|
22
|
+
} from "./taxonomy.js";
|
|
23
|
+
import { countEnglishWords } from "./analytics.js";
|
|
24
|
+
import {
|
|
25
|
+
HistorySchemaMismatchError,
|
|
26
|
+
decodeHistoryLine,
|
|
27
|
+
encodeHistoryEvent,
|
|
28
|
+
} from "./history-codec.js";
|
|
29
|
+
import {
|
|
30
|
+
copyAnalysisResult,
|
|
31
|
+
copyObservation,
|
|
32
|
+
copyOccurrence,
|
|
33
|
+
copyPattern,
|
|
34
|
+
createFluencyState,
|
|
35
|
+
reduceHistoryEvent,
|
|
36
|
+
replaceFluencyState,
|
|
37
|
+
} from "./state-reducer.js";
|
|
38
|
+
import { buildRetainedSnapshot } from "./retention.js";
|
|
39
|
+
import {
|
|
40
|
+
decodeHistoryGenerationMarker,
|
|
41
|
+
encodeHistoryGenerationMarker,
|
|
42
|
+
} from "./generation-marker.js";
|
|
43
|
+
|
|
44
|
+
const HISTORY_SCHEMA_WARNING = "History migration required; run /fluency clear";
|
|
45
|
+
const HISTORY_GENERATION_FILE = "history-generation";
|
|
46
|
+
const PRIVATE_DIRECTORY_MODE = 0o700;
|
|
47
|
+
const PRIVATE_FILE_MODE = 0o600;
|
|
48
|
+
const LOCK_STALE_AFTER_MS = 30_000;
|
|
49
|
+
const LOCK_UPDATE_INTERVAL_MS = 10_000;
|
|
50
|
+
const LOCK_RETRIES = {
|
|
51
|
+
retries: 60,
|
|
52
|
+
factor: 1.2,
|
|
53
|
+
minTimeout: 100,
|
|
54
|
+
maxTimeout: 1_000,
|
|
55
|
+
randomize: true,
|
|
56
|
+
} as const;
|
|
57
|
+
|
|
58
|
+
type LockProvider = (file: string, options: LockOptions) => Promise<() => Promise<void>>;
|
|
59
|
+
type FileReplacer = (temporary: string, destination: string) => Promise<void>;
|
|
60
|
+
|
|
61
|
+
const errantCategorySet = new Set<string>(ERRANT_CATEGORIES);
|
|
62
|
+
|
|
63
|
+
function copySettings(settings: FluencySettings): FluencySettings {
|
|
64
|
+
return {
|
|
65
|
+
...settings,
|
|
66
|
+
ignoredPatternKeys: [...settings.ignoredPatternKeys],
|
|
67
|
+
ignoredCategories: [...settings.ignoredCategories],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function copySettingsPatch(patch: unknown): Partial<FluencySettings> {
|
|
72
|
+
if (!patch || typeof patch !== "object" || Array.isArray(patch)) throw new Error("Invalid settings");
|
|
73
|
+
const raw = patch as Record<string, unknown>;
|
|
74
|
+
if (raw.ignoredPatternKeys !== undefined && !Array.isArray(raw.ignoredPatternKeys)) throw new Error("Invalid settings");
|
|
75
|
+
if (raw.ignoredCategories !== undefined && !Array.isArray(raw.ignoredCategories)) throw new Error("Invalid settings");
|
|
76
|
+
return {
|
|
77
|
+
...(patch as Partial<FluencySettings>),
|
|
78
|
+
...(raw.ignoredPatternKeys === undefined ? {} : { ignoredPatternKeys: [...raw.ignoredPatternKeys] as string[] }),
|
|
79
|
+
...(raw.ignoredCategories === undefined ? {} : { ignoredCategories: [...raw.ignoredCategories] as ErrantCategory[] }),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function decodeSettings(value: unknown): FluencySettings {
|
|
84
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid settings");
|
|
85
|
+
const raw = value as Record<string, unknown>;
|
|
86
|
+
if (
|
|
87
|
+
raw.schemaVersion !== SCHEMA_VERSION
|
|
88
|
+
|| "ignoredClassIds" in raw
|
|
89
|
+
|| typeof raw.enabled !== "boolean"
|
|
90
|
+
|| typeof raw.minimumConfidence !== "number"
|
|
91
|
+
|| !Number.isFinite(raw.minimumConfidence)
|
|
92
|
+
|| raw.minimumConfidence < 0
|
|
93
|
+
|| raw.minimumConfidence > 1
|
|
94
|
+
|| typeof raw.retentionLimit !== "number"
|
|
95
|
+
|| !Number.isInteger(raw.retentionLimit)
|
|
96
|
+
|| raw.retentionLimit < 0
|
|
97
|
+
|| !Array.isArray(raw.ignoredPatternKeys)
|
|
98
|
+
|| raw.ignoredPatternKeys.some((item) => typeof item !== "string" || item.length === 0)
|
|
99
|
+
|| !Array.isArray(raw.ignoredCategories)
|
|
100
|
+
|| raw.ignoredCategories.some((item) => typeof item !== "string" || !errantCategorySet.has(item))
|
|
101
|
+
|| (raw.consentedAt !== undefined && (typeof raw.consentedAt !== "number" || !Number.isFinite(raw.consentedAt)))
|
|
102
|
+
|| (raw.provider !== undefined && (typeof raw.provider !== "string" || raw.provider.length === 0))
|
|
103
|
+
|| (raw.modelId !== undefined && (typeof raw.modelId !== "string" || raw.modelId.length === 0))
|
|
104
|
+
) throw new Error("Invalid settings");
|
|
105
|
+
return {
|
|
106
|
+
schemaVersion: SCHEMA_VERSION,
|
|
107
|
+
enabled: raw.enabled,
|
|
108
|
+
minimumConfidence: raw.minimumConfidence,
|
|
109
|
+
retentionLimit: raw.retentionLimit,
|
|
110
|
+
ignoredPatternKeys: [...new Set(raw.ignoredPatternKeys as string[])],
|
|
111
|
+
ignoredCategories: [...new Set(raw.ignoredCategories as ErrantCategory[])],
|
|
112
|
+
...(raw.consentedAt === undefined ? {} : { consentedAt: raw.consentedAt as number }),
|
|
113
|
+
...(raw.provider === undefined ? {} : { provider: raw.provider as string }),
|
|
114
|
+
...(raw.modelId === undefined ? {} : { modelId: raw.modelId as string }),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export class FluencyStore {
|
|
119
|
+
private static lockProvider: LockProvider = lock;
|
|
120
|
+
private static settingsFileReplacer: FileReplacer = rename;
|
|
121
|
+
private static historyFileReplacer: FileReplacer = rename;
|
|
122
|
+
|
|
123
|
+
private readonly state = createFluencyState();
|
|
124
|
+
private readonly warnings: string[] = [];
|
|
125
|
+
private readonly pendingEvents: Array<{ generation: string; event: FluencyEvent }> = [];
|
|
126
|
+
private historyGeneration = "";
|
|
127
|
+
private eventsSinceCompact = 0;
|
|
128
|
+
private historyResetRequired = false;
|
|
129
|
+
private settings: FluencySettings = copySettings(DEFAULT_SETTINGS);
|
|
130
|
+
private mutationQueue: Promise<void> = Promise.resolve();
|
|
131
|
+
|
|
132
|
+
private constructor(
|
|
133
|
+
private readonly rootDir: string,
|
|
134
|
+
private readonly historyPath: string,
|
|
135
|
+
private readonly settingsPath: string,
|
|
136
|
+
private readonly historyGenerationPath: string,
|
|
137
|
+
) {}
|
|
138
|
+
|
|
139
|
+
static async open(rootDir: string): Promise<FluencyStore> {
|
|
140
|
+
await mkdir(rootDir, { recursive: true, mode: PRIVATE_DIRECTORY_MODE });
|
|
141
|
+
await chmod(rootDir, PRIVATE_DIRECTORY_MODE);
|
|
142
|
+
const store = new FluencyStore(
|
|
143
|
+
rootDir,
|
|
144
|
+
join(rootDir, "history.jsonl"),
|
|
145
|
+
join(rootDir, "settings.json"),
|
|
146
|
+
join(rootDir, HISTORY_GENERATION_FILE),
|
|
147
|
+
);
|
|
148
|
+
await store.withGlobalLock(async (signal) => {
|
|
149
|
+
await store.hardenExistingFiles();
|
|
150
|
+
await store.refreshFromDiskUnsafe(true, signal);
|
|
151
|
+
await store.hardenExistingFiles();
|
|
152
|
+
});
|
|
153
|
+
return store;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
getAnalyticsSnapshot(): FluencyAnalyticsSnapshot {
|
|
157
|
+
return {
|
|
158
|
+
observations: [...this.state.observations.values()].map(copyObservation),
|
|
159
|
+
occurrences: [...this.state.occurrences.values()].map(copyOccurrence),
|
|
160
|
+
patterns: [...this.state.patterns.values()].map(copyPattern),
|
|
161
|
+
ignoredPatternKeys: [...this.settings.ignoredPatternKeys],
|
|
162
|
+
ignoredCategories: [...this.settings.ignoredCategories],
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
hasProcessedPromptHash(promptHash: string): boolean {
|
|
167
|
+
return this.state.processedPromptHashes.has(promptHash);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
requiresHistoryReset(): boolean { return this.historyResetRequired; }
|
|
171
|
+
getSettings(): FluencySettings { return copySettings(this.settings); }
|
|
172
|
+
getWarnings(): string[] { return [...this.warnings]; }
|
|
173
|
+
|
|
174
|
+
private async hardenExistingFiles(): Promise<void> {
|
|
175
|
+
for (const path of [this.historyPath, this.settingsPath, this.historyGenerationPath]) {
|
|
176
|
+
try {
|
|
177
|
+
await chmod(path, PRIVATE_FILE_MODE);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private enqueueMutation<T>(mutation: (signal: AbortSignal) => Promise<T>): Promise<T> {
|
|
185
|
+
const result = this.mutationQueue.then(() => this.withGlobalLock(async (signal) => {
|
|
186
|
+
await this.refreshFromDiskUnsafe(false, signal);
|
|
187
|
+
signal.throwIfAborted();
|
|
188
|
+
return mutation(signal);
|
|
189
|
+
}));
|
|
190
|
+
this.mutationQueue = result.then(() => undefined, () => undefined);
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private async withGlobalLock<T>(operation: (signal: AbortSignal) => Promise<T>): Promise<T> {
|
|
195
|
+
const controller = new AbortController();
|
|
196
|
+
let compromiseError: Error | undefined;
|
|
197
|
+
const release = await FluencyStore.lockProvider(this.rootDir, {
|
|
198
|
+
realpath: false,
|
|
199
|
+
stale: LOCK_STALE_AFTER_MS,
|
|
200
|
+
update: LOCK_UPDATE_INTERVAL_MS,
|
|
201
|
+
retries: LOCK_RETRIES,
|
|
202
|
+
onCompromised: (error) => {
|
|
203
|
+
compromiseError ??= error;
|
|
204
|
+
controller.abort(compromiseError);
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
let value: T | undefined;
|
|
209
|
+
let primaryError: unknown;
|
|
210
|
+
try {
|
|
211
|
+
value = await operation(controller.signal);
|
|
212
|
+
controller.signal.throwIfAborted();
|
|
213
|
+
} catch (error) {
|
|
214
|
+
primaryError = compromiseError ?? error;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
await release();
|
|
219
|
+
} catch (releaseError) {
|
|
220
|
+
const expectedCompromiseRelease = compromiseError !== undefined &&
|
|
221
|
+
(releaseError as NodeJS.ErrnoException).code === "ERELEASED";
|
|
222
|
+
if (primaryError === undefined && !expectedCompromiseRelease) throw releaseError;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (compromiseError !== undefined) throw compromiseError;
|
|
226
|
+
if (primaryError !== undefined) throw primaryError;
|
|
227
|
+
return value as T;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private async writeHistoryGenerationUnsafe(
|
|
231
|
+
generation: string,
|
|
232
|
+
resetPending: boolean,
|
|
233
|
+
signal: AbortSignal,
|
|
234
|
+
): Promise<void> {
|
|
235
|
+
const temporary = `${this.historyGenerationPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
236
|
+
await writeFile(temporary, encodeHistoryGenerationMarker({ generation, resetPending }), {
|
|
237
|
+
encoding: "utf8",
|
|
238
|
+
mode: PRIVATE_FILE_MODE,
|
|
239
|
+
signal,
|
|
240
|
+
});
|
|
241
|
+
signal.throwIfAborted();
|
|
242
|
+
await rename(temporary, this.historyGenerationPath);
|
|
243
|
+
signal.throwIfAborted();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private async replaceHistoryWithEmptyUnsafe(signal: AbortSignal): Promise<void> {
|
|
247
|
+
const temporary = `${this.historyPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
248
|
+
await writeFile(temporary, "", { encoding: "utf8", mode: PRIVATE_FILE_MODE, signal });
|
|
249
|
+
signal.throwIfAborted();
|
|
250
|
+
await FluencyStore.historyFileReplacer(temporary, this.historyPath);
|
|
251
|
+
signal.throwIfAborted();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private async readHistoryGenerationUnsafe(signal: AbortSignal): Promise<string> {
|
|
255
|
+
let generation: string;
|
|
256
|
+
let resetPending = false;
|
|
257
|
+
try {
|
|
258
|
+
const marker = decodeHistoryGenerationMarker(
|
|
259
|
+
await readFile(this.historyGenerationPath, { encoding: "utf8", signal }),
|
|
260
|
+
);
|
|
261
|
+
signal.throwIfAborted();
|
|
262
|
+
generation = marker.generation;
|
|
263
|
+
resetPending = marker.resetPending;
|
|
264
|
+
if (marker.legacy) await this.writeHistoryGenerationUnsafe(generation, false, signal);
|
|
265
|
+
} catch (error) {
|
|
266
|
+
signal.throwIfAborted();
|
|
267
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
268
|
+
generation = randomUUID();
|
|
269
|
+
await this.writeHistoryGenerationUnsafe(generation, false, signal);
|
|
270
|
+
}
|
|
271
|
+
if (resetPending) {
|
|
272
|
+
await this.replaceHistoryWithEmptyUnsafe(signal);
|
|
273
|
+
await this.writeHistoryGenerationUnsafe(generation, false, signal);
|
|
274
|
+
}
|
|
275
|
+
return generation;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private async refreshFromDiskUnsafe(recordWarnings: boolean, signal: AbortSignal): Promise<void> {
|
|
279
|
+
const diskGeneration = await this.readHistoryGenerationUnsafe(signal);
|
|
280
|
+
for (let index = this.pendingEvents.length - 1; index >= 0; index -= 1) {
|
|
281
|
+
if (this.pendingEvents[index]!.generation !== diskGeneration) this.pendingEvents.splice(index, 1);
|
|
282
|
+
}
|
|
283
|
+
this.historyGeneration = diskGeneration;
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
const parsed = JSON.parse(await readFile(this.settingsPath, { encoding: "utf8", signal })) as unknown;
|
|
287
|
+
signal.throwIfAborted();
|
|
288
|
+
this.settings = decodeSettings(parsed);
|
|
289
|
+
} catch (error) {
|
|
290
|
+
signal.throwIfAborted();
|
|
291
|
+
this.settings = copySettings(DEFAULT_SETTINGS);
|
|
292
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT" && recordWarnings) {
|
|
293
|
+
this.warnings.push("Could not read settings; defaults loaded");
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let history: string;
|
|
298
|
+
try {
|
|
299
|
+
history = await readFile(this.historyPath, { encoding: "utf8", signal });
|
|
300
|
+
signal.throwIfAborted();
|
|
301
|
+
} catch (error) {
|
|
302
|
+
signal.throwIfAborted();
|
|
303
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
304
|
+
signal.throwIfAborted();
|
|
305
|
+
await writeFile(this.historyPath, "", { encoding: "utf8", mode: PRIVATE_FILE_MODE, signal });
|
|
306
|
+
signal.throwIfAborted();
|
|
307
|
+
history = "";
|
|
308
|
+
} else {
|
|
309
|
+
throw error;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const refreshed = createFluencyState();
|
|
314
|
+
let corrupt = 0;
|
|
315
|
+
let eventsSinceCompact = 0;
|
|
316
|
+
for (const line of history.split("\n")) {
|
|
317
|
+
if (!line.trim()) continue;
|
|
318
|
+
try {
|
|
319
|
+
const event = decodeHistoryLine(JSON.parse(line) as unknown);
|
|
320
|
+
reduceHistoryEvent(refreshed, event);
|
|
321
|
+
eventsSinceCompact = event.type === "snapshot" ? 0 : eventsSinceCompact + 1;
|
|
322
|
+
} catch (error) {
|
|
323
|
+
if (error instanceof HistorySchemaMismatchError) {
|
|
324
|
+
this.historyResetRequired = true;
|
|
325
|
+
if (!this.warnings.includes(HISTORY_SCHEMA_WARNING)) this.warnings.push(HISTORY_SCHEMA_WARNING);
|
|
326
|
+
replaceFluencyState(this.state, createFluencyState());
|
|
327
|
+
this.eventsSinceCompact = 0;
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
corrupt += 1;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
this.historyResetRequired = false;
|
|
334
|
+
for (let index = this.warnings.indexOf(HISTORY_SCHEMA_WARNING); index >= 0; index = this.warnings.indexOf(HISTORY_SCHEMA_WARNING)) {
|
|
335
|
+
this.warnings.splice(index, 1);
|
|
336
|
+
}
|
|
337
|
+
for (const pending of this.pendingEvents) reduceHistoryEvent(refreshed, pending.event);
|
|
338
|
+
signal.throwIfAborted();
|
|
339
|
+
replaceFluencyState(this.state, refreshed);
|
|
340
|
+
this.eventsSinceCompact = eventsSinceCompact + this.pendingEvents.length;
|
|
341
|
+
if (recordWarnings && corrupt > 0) {
|
|
342
|
+
this.warnings.push(`Skipped ${corrupt} corrupt history ${corrupt === 1 ? "line" : "lines"}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private isIgnored(pattern: MistakePattern): boolean {
|
|
347
|
+
return this.settings.ignoredPatternKeys.includes(pattern.patternKey) ||
|
|
348
|
+
this.settings.ignoredCategories.includes(errantCategory(pattern.errorType));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
listReviewPatterns(): ReviewPattern[] {
|
|
352
|
+
const counts = new Map<string, {
|
|
353
|
+
pendingCount: number;
|
|
354
|
+
acceptedCount: number;
|
|
355
|
+
dismissedCount: number;
|
|
356
|
+
}>();
|
|
357
|
+
for (const occurrence of this.state.occurrences.values()) {
|
|
358
|
+
const current = counts.get(occurrence.patternId) ?? {
|
|
359
|
+
pendingCount: 0,
|
|
360
|
+
acceptedCount: 0,
|
|
361
|
+
dismissedCount: 0,
|
|
362
|
+
};
|
|
363
|
+
if (occurrence.decision === "pending") current.pendingCount += 1;
|
|
364
|
+
else if (occurrence.decision === "accepted") current.acceptedCount += 1;
|
|
365
|
+
else current.dismissedCount += 1;
|
|
366
|
+
counts.set(occurrence.patternId, current);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return [...this.state.patterns.values()]
|
|
370
|
+
.map((pattern) => ({
|
|
371
|
+
...pattern,
|
|
372
|
+
...(counts.get(pattern.id) ?? {
|
|
373
|
+
pendingCount: 0,
|
|
374
|
+
acceptedCount: 0,
|
|
375
|
+
dismissedCount: 0,
|
|
376
|
+
}),
|
|
377
|
+
}))
|
|
378
|
+
.sort((left, right) => right.lastSeenAt - left.lastSeenAt);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
listInbox(): ReviewPattern[] {
|
|
382
|
+
return this.listReviewPatterns()
|
|
383
|
+
.filter((pattern) => pattern.pendingCount > 0 && !this.isIgnored(pattern));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
listAccepted(): ReviewPattern[] {
|
|
387
|
+
return this.listReviewPatterns()
|
|
388
|
+
.filter((pattern) => pattern.acceptedCount > 0 && !this.isIgnored(pattern));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
listIgnored(): ReviewPattern[] {
|
|
392
|
+
return this.listReviewPatterns().filter((pattern) => this.isIgnored(pattern));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
listKnownPatterns(): MistakePattern[] {
|
|
396
|
+
return this.listReviewPatterns()
|
|
397
|
+
.filter((pattern) => !this.isIgnored(pattern) && (pattern.pendingCount > 0 || pattern.acceptedCount > 0))
|
|
398
|
+
.map(({ pendingCount: _pending, acceptedCount: _accepted, dismissedCount: _dismissed, ...pattern }) => pattern);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
ignorePatternKey(patternKey: string): Promise<void> {
|
|
402
|
+
return this.updateSettings((settings) => ({
|
|
403
|
+
ignoredPatternKeys: [...new Set([...settings.ignoredPatternKeys, patternKey])],
|
|
404
|
+
}));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
ignoreCategory(category: ErrantCategory): Promise<void> {
|
|
408
|
+
return this.updateSettings((settings) => ({
|
|
409
|
+
ignoredCategories: [...new Set([...settings.ignoredCategories, category])],
|
|
410
|
+
}));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Restore every applicable ignore in one queued settings mutation and atomic file replacement. */
|
|
414
|
+
restoreIgnoreTargets(targets: {
|
|
415
|
+
patternKeys: readonly string[];
|
|
416
|
+
categories: readonly ErrantCategory[];
|
|
417
|
+
}): Promise<void> {
|
|
418
|
+
const patternKeys = new Set(targets.patternKeys);
|
|
419
|
+
const categories = new Set(targets.categories);
|
|
420
|
+
return this.updateSettings((settings) => ({
|
|
421
|
+
ignoredPatternKeys: settings.ignoredPatternKeys.filter((value) => !patternKeys.has(value)),
|
|
422
|
+
ignoredCategories: settings.ignoredCategories.filter((value) => !categories.has(value)),
|
|
423
|
+
}));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
updateSettings(
|
|
427
|
+
patchOrMutator: Partial<FluencySettings> | ((settings: FluencySettings) => Partial<FluencySettings>),
|
|
428
|
+
): Promise<void> {
|
|
429
|
+
let mutator: ((settings: FluencySettings) => Partial<FluencySettings>) | undefined;
|
|
430
|
+
let directPatch: Partial<FluencySettings> = {};
|
|
431
|
+
if (typeof patchOrMutator === "function") mutator = patchOrMutator;
|
|
432
|
+
else {
|
|
433
|
+
try {
|
|
434
|
+
directPatch = copySettingsPatch(patchOrMutator);
|
|
435
|
+
} catch (error) {
|
|
436
|
+
return Promise.reject(error);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return this.enqueueMutation(async (signal) => {
|
|
440
|
+
const patch = mutator
|
|
441
|
+
? copySettingsPatch(mutator(copySettings(this.settings)))
|
|
442
|
+
: directPatch;
|
|
443
|
+
await this.saveSettingsUnsafe({ ...this.settings, ...patch }, signal);
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private async saveSettingsUnsafe(settings: FluencySettings, signal: AbortSignal): Promise<void> {
|
|
448
|
+
const copied = decodeSettings(settings);
|
|
449
|
+
const temporary = `${this.settingsPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
450
|
+
signal.throwIfAborted();
|
|
451
|
+
await writeFile(temporary, `${JSON.stringify(copied, null, 2)}\n`, { encoding: "utf8", mode: PRIVATE_FILE_MODE, signal });
|
|
452
|
+
signal.throwIfAborted();
|
|
453
|
+
await FluencyStore.settingsFileReplacer(temporary, this.settingsPath);
|
|
454
|
+
signal.throwIfAborted();
|
|
455
|
+
this.settings = copied;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
appendAnalysis(prompt: CollectedPrompt, result: AnalysisResult): Promise<void> {
|
|
459
|
+
const copiedPrompt: CollectedPrompt = {
|
|
460
|
+
promptHash: prompt.promptHash,
|
|
461
|
+
prose: prompt.prose,
|
|
462
|
+
observedAt: prompt.observedAt,
|
|
463
|
+
};
|
|
464
|
+
let event: FluencyEvent;
|
|
465
|
+
try {
|
|
466
|
+
if (result.schemaVersion !== 3) throw new Error("Invalid schema-v4 history event");
|
|
467
|
+
const sanitizedResult = copyAnalysisResult(result);
|
|
468
|
+
event = decodeHistoryLine({
|
|
469
|
+
schemaVersion: HISTORY_SCHEMA_VERSION,
|
|
470
|
+
type: "analysis",
|
|
471
|
+
at: copiedPrompt.observedAt,
|
|
472
|
+
prompt: copiedPrompt,
|
|
473
|
+
wordCount: countEnglishWords(copiedPrompt.prose),
|
|
474
|
+
result: sanitizedResult,
|
|
475
|
+
});
|
|
476
|
+
} catch (error) {
|
|
477
|
+
return Promise.reject(error);
|
|
478
|
+
}
|
|
479
|
+
return this.enqueueMutation((signal) => {
|
|
480
|
+
this.assertHistoryReady();
|
|
481
|
+
return this.appendUnsafe(event, signal);
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private reviewPatternBatch(
|
|
486
|
+
patternId: string,
|
|
487
|
+
decision: "accepted" | "dismissed",
|
|
488
|
+
at = Date.now(),
|
|
489
|
+
): Promise<void> {
|
|
490
|
+
return this.enqueueMutation(async (signal) => {
|
|
491
|
+
signal.throwIfAborted();
|
|
492
|
+
this.assertHistoryReady();
|
|
493
|
+
const pattern = this.state.patterns.get(patternId);
|
|
494
|
+
if (!pattern) throw new Error(`Unknown pattern: ${patternId}`);
|
|
495
|
+
const occurrenceIds = [...this.state.occurrences.values()]
|
|
496
|
+
.filter((occurrence) => occurrence.patternId === patternId && occurrence.decision === "pending")
|
|
497
|
+
.map((occurrence) => occurrence.id);
|
|
498
|
+
if (occurrenceIds.length === 0) return;
|
|
499
|
+
await this.appendUnsafe({
|
|
500
|
+
schemaVersion: HISTORY_SCHEMA_VERSION,
|
|
501
|
+
type: "review",
|
|
502
|
+
at,
|
|
503
|
+
occurrenceIds,
|
|
504
|
+
decision,
|
|
505
|
+
}, signal);
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
acceptPattern(patternId: string, at = Date.now()): Promise<void> {
|
|
510
|
+
return this.reviewPatternBatch(patternId, "accepted", at);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
dismissPattern(patternId: string, at = Date.now()): Promise<void> {
|
|
514
|
+
return this.reviewPatternBatch(patternId, "dismissed", at);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
clear(): Promise<void> {
|
|
518
|
+
return this.enqueueMutation((signal) => this.clearUnsafe(signal));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
private async clearUnsafe(signal: AbortSignal): Promise<void> {
|
|
522
|
+
const generation = randomUUID();
|
|
523
|
+
signal.throwIfAborted();
|
|
524
|
+
await this.writeHistoryGenerationUnsafe(generation, true, signal);
|
|
525
|
+
this.historyGeneration = generation;
|
|
526
|
+
this.pendingEvents.length = 0;
|
|
527
|
+
await this.replaceHistoryWithEmptyUnsafe(signal);
|
|
528
|
+
await this.writeHistoryGenerationUnsafe(generation, false, signal);
|
|
529
|
+
this.state.patterns.clear();
|
|
530
|
+
this.state.observations.clear();
|
|
531
|
+
this.state.occurrences.clear();
|
|
532
|
+
this.state.processedPromptHashes.clear();
|
|
533
|
+
this.eventsSinceCompact = 0;
|
|
534
|
+
this.historyResetRequired = false;
|
|
535
|
+
for (let index = this.warnings.indexOf(HISTORY_SCHEMA_WARNING); index >= 0; index = this.warnings.indexOf(HISTORY_SCHEMA_WARNING)) {
|
|
536
|
+
this.warnings.splice(index, 1);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
private assertHistoryReady(): void {
|
|
541
|
+
if (this.historyResetRequired) throw new HistorySchemaMismatchError();
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
compact(at = Date.now()): Promise<void> {
|
|
545
|
+
return this.enqueueMutation((signal) => this.compactUnsafe(signal, at));
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
private async compactUnsafe(signal: AbortSignal, at = Date.now()): Promise<void> {
|
|
549
|
+
this.assertHistoryReady();
|
|
550
|
+
signal.throwIfAborted();
|
|
551
|
+
const event = buildRetainedSnapshot(this.state, {
|
|
552
|
+
now: at,
|
|
553
|
+
retentionLimit: this.settings.retentionLimit,
|
|
554
|
+
});
|
|
555
|
+
const temporary = `${this.historyPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
556
|
+
signal.throwIfAborted();
|
|
557
|
+
await writeFile(temporary, `${encodeHistoryEvent(event)}\n`, { encoding: "utf8", mode: PRIVATE_FILE_MODE, signal });
|
|
558
|
+
signal.throwIfAborted();
|
|
559
|
+
await FluencyStore.historyFileReplacer(temporary, this.historyPath);
|
|
560
|
+
signal.throwIfAborted();
|
|
561
|
+
reduceHistoryEvent(this.state, event);
|
|
562
|
+
this.pendingEvents.length = 0;
|
|
563
|
+
this.eventsSinceCompact = 0;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
private async appendUnsafe(event: FluencyEvent, signal: AbortSignal): Promise<void> {
|
|
567
|
+
const serialized = [...this.pendingEvents.map((pending) => pending.event), event]
|
|
568
|
+
.map(encodeHistoryEvent)
|
|
569
|
+
.join("\n") + "\n";
|
|
570
|
+
try {
|
|
571
|
+
signal.throwIfAborted();
|
|
572
|
+
await appendFile(this.historyPath, serialized, { encoding: "utf8", mode: PRIVATE_FILE_MODE });
|
|
573
|
+
signal.throwIfAborted();
|
|
574
|
+
this.pendingEvents.length = 0;
|
|
575
|
+
} catch (error) {
|
|
576
|
+
signal.throwIfAborted();
|
|
577
|
+
this.pendingEvents.push({ generation: this.historyGeneration, event });
|
|
578
|
+
reduceHistoryEvent(this.state, event);
|
|
579
|
+
this.warnings.push("History write failed; event retained in memory");
|
|
580
|
+
throw error;
|
|
581
|
+
}
|
|
582
|
+
signal.throwIfAborted();
|
|
583
|
+
reduceHistoryEvent(this.state, event);
|
|
584
|
+
this.eventsSinceCompact += 1;
|
|
585
|
+
if (this.eventsSinceCompact >= 100 || this.state.patterns.size > this.settings.retentionLimit) {
|
|
586
|
+
await this.compactUnsafe(signal);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export const ERRANT_OPERATIONS = ["M", "U", "R"] as const;
|
|
2
|
+
export type ErrantOperation = (typeof ERRANT_OPERATIONS)[number];
|
|
3
|
+
|
|
4
|
+
export const ERRANT_CATEGORIES = [
|
|
5
|
+
"ADJ",
|
|
6
|
+
"ADJ:FORM",
|
|
7
|
+
"ADV",
|
|
8
|
+
"CONJ",
|
|
9
|
+
"CONTR",
|
|
10
|
+
"DET",
|
|
11
|
+
"MORPH",
|
|
12
|
+
"NOUN",
|
|
13
|
+
"NOUN:INFL",
|
|
14
|
+
"NOUN:NUM",
|
|
15
|
+
"NOUN:POSS",
|
|
16
|
+
"ORTH",
|
|
17
|
+
"OTHER",
|
|
18
|
+
"PART",
|
|
19
|
+
"PREP",
|
|
20
|
+
"PRON",
|
|
21
|
+
"PUNCT",
|
|
22
|
+
"SPELL",
|
|
23
|
+
"VERB",
|
|
24
|
+
"VERB:FORM",
|
|
25
|
+
"VERB:INFL",
|
|
26
|
+
"VERB:SVA",
|
|
27
|
+
"VERB:TENSE",
|
|
28
|
+
"WO",
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
export type ErrantCategory = (typeof ERRANT_CATEGORIES)[number];
|
|
32
|
+
export type ErrantErrorType = `${ErrantOperation}:${ErrantCategory}`;
|
|
33
|
+
|
|
34
|
+
export const ERRANT_ERROR_TYPES: ErrantErrorType[] = ERRANT_OPERATIONS.flatMap((operation) =>
|
|
35
|
+
ERRANT_CATEGORIES.map((category) => `${operation}:${category}` as ErrantErrorType),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const ERRANT_ERROR_TYPE_SET = new Set<string>(ERRANT_ERROR_TYPES);
|
|
39
|
+
|
|
40
|
+
export function isErrantErrorType(value: unknown): value is ErrantErrorType {
|
|
41
|
+
return typeof value === "string" && ERRANT_ERROR_TYPE_SET.has(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function errantCategory(type: ErrantErrorType): ErrantCategory {
|
|
45
|
+
return type.slice(2) as ErrantCategory;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const ERRANT_CATEGORY_LABELS = {
|
|
49
|
+
ADJ: "Adjective",
|
|
50
|
+
"ADJ:FORM": "Adjective form",
|
|
51
|
+
ADV: "Adverb",
|
|
52
|
+
CONJ: "Conjunction",
|
|
53
|
+
CONTR: "Contraction",
|
|
54
|
+
DET: "Determiner",
|
|
55
|
+
MORPH: "Morphology",
|
|
56
|
+
NOUN: "Noun",
|
|
57
|
+
"NOUN:INFL": "Noun inflection",
|
|
58
|
+
"NOUN:NUM": "Noun number",
|
|
59
|
+
"NOUN:POSS": "Noun possessive",
|
|
60
|
+
ORTH: "Capitalization / spacing",
|
|
61
|
+
OTHER: "Other",
|
|
62
|
+
PART: "Particle",
|
|
63
|
+
PREP: "Preposition",
|
|
64
|
+
PRON: "Pronoun",
|
|
65
|
+
PUNCT: "Punctuation",
|
|
66
|
+
SPELL: "Spelling",
|
|
67
|
+
VERB: "Verb",
|
|
68
|
+
"VERB:FORM": "Verb form",
|
|
69
|
+
"VERB:INFL": "Verb inflection",
|
|
70
|
+
"VERB:SVA": "Subject–verb agreement",
|
|
71
|
+
"VERB:TENSE": "Verb tense",
|
|
72
|
+
WO: "Word order",
|
|
73
|
+
} as const satisfies Record<ErrantCategory, string>;
|