pi-fluency 0.1.3 → 0.2.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 +30 -5
- package/extensions/pi-fluency/analytics.ts +151 -0
- package/extensions/pi-fluency/analyzer.ts +29 -3
- package/extensions/pi-fluency/coaching-overlay.ts +259 -0
- package/extensions/pi-fluency/coaching.ts +158 -0
- package/extensions/pi-fluency/collector.ts +3 -2
- package/extensions/pi-fluency/index.ts +538 -27
- package/extensions/pi-fluency/overlay.ts +296 -40
- package/extensions/pi-fluency/practice-settings.ts +201 -0
- package/extensions/pi-fluency/setup.ts +1 -1
- package/extensions/pi-fluency/status.ts +3 -0
- package/extensions/pi-fluency/store.ts +294 -13
- package/extensions/pi-fluency/types.ts +50 -0
- package/extensions/pi-fluency/worker.ts +408 -27
- package/package.json +1 -1
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_PRACTICE_SETTINGS,
|
|
4
|
+
PRACTICE_SCHEMA_VERSION,
|
|
5
|
+
type PracticeSettings,
|
|
6
|
+
type PracticeTarget,
|
|
7
|
+
} from "./types.js";
|
|
8
|
+
|
|
9
|
+
export const MAX_PRACTICE_TARGETS = 50;
|
|
10
|
+
export const MAX_PRACTICE_FIELD_LENGTH = 500;
|
|
11
|
+
export const MAX_PRACTICE_MEMBER_KEYS = 500;
|
|
12
|
+
export const FIVE_HOURS_MS = 5 * 60 * 60 * 1_000;
|
|
13
|
+
export const PRACTICE_SESSION_ENTRY_TYPE = "pi-fluency-practice-snooze";
|
|
14
|
+
export const PRACTICE_SESSION_RESUME_ENTRY_TYPE = "pi-fluency-practice-resume";
|
|
15
|
+
|
|
16
|
+
const CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f]/;
|
|
17
|
+
const PRACTICE_KEYS = new Set([
|
|
18
|
+
"schemaVersion",
|
|
19
|
+
"revision",
|
|
20
|
+
"epoch",
|
|
21
|
+
"enabled",
|
|
22
|
+
"consentedAt",
|
|
23
|
+
"targets",
|
|
24
|
+
"snoozedUntil",
|
|
25
|
+
]);
|
|
26
|
+
const TARGET_KEYS = new Set(["explanation", "memberPatternKeys"]);
|
|
27
|
+
const SESSION_SNOOZE_KEYS = new Set(["schemaVersion", "epoch", "sessionHash"]);
|
|
28
|
+
|
|
29
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
30
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function hasOnlyKeys(value: Record<string, unknown>, allowed: ReadonlySet<string>): boolean {
|
|
34
|
+
return Object.keys(value).every((key) => allowed.has(key));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isCounter(value: unknown): value is number {
|
|
38
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isTimestamp(value: unknown): value is number {
|
|
42
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isPracticeField(value: unknown): value is string {
|
|
46
|
+
return typeof value === "string"
|
|
47
|
+
&& value.length > 0
|
|
48
|
+
&& value.length <= MAX_PRACTICE_FIELD_LENGTH
|
|
49
|
+
&& !CONTROL_CHARACTER.test(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function canonicalizePracticeTargets(value: unknown): PracticeTarget[] {
|
|
53
|
+
if (!Array.isArray(value) || value.length > MAX_PRACTICE_TARGETS) {
|
|
54
|
+
throw new Error("Invalid practice settings");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const membersByExplanation = new Map<string, Set<string>>();
|
|
58
|
+
for (const candidate of value) {
|
|
59
|
+
if (!isRecord(candidate) || !hasOnlyKeys(candidate, TARGET_KEYS)
|
|
60
|
+
|| !isPracticeField(candidate.explanation)
|
|
61
|
+
|| !Array.isArray(candidate.memberPatternKeys)
|
|
62
|
+
|| candidate.memberPatternKeys.length === 0
|
|
63
|
+
|| candidate.memberPatternKeys.length > MAX_PRACTICE_MEMBER_KEYS
|
|
64
|
+
|| candidate.memberPatternKeys.some((key) => !isPracticeField(key))) {
|
|
65
|
+
throw new Error("Invalid practice settings");
|
|
66
|
+
}
|
|
67
|
+
const members = membersByExplanation.get(candidate.explanation) ?? new Set<string>();
|
|
68
|
+
for (const key of candidate.memberPatternKeys as string[]) members.add(key);
|
|
69
|
+
if (members.size > MAX_PRACTICE_MEMBER_KEYS) throw new Error("Invalid practice settings");
|
|
70
|
+
membersByExplanation.set(candidate.explanation, members);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return [...membersByExplanation]
|
|
74
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
75
|
+
.map(([explanation, memberPatternKeys]) => ({
|
|
76
|
+
explanation,
|
|
77
|
+
memberPatternKeys: [...memberPatternKeys].sort((left, right) => left.localeCompare(right)),
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function copyPracticeSettings(settings: PracticeSettings): PracticeSettings {
|
|
82
|
+
return {
|
|
83
|
+
...settings,
|
|
84
|
+
targets: settings.targets.map((target) => ({
|
|
85
|
+
explanation: target.explanation,
|
|
86
|
+
memberPatternKeys: [...target.memberPatternKeys],
|
|
87
|
+
})),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function defaultPracticeSettings(): PracticeSettings {
|
|
92
|
+
return copyPracticeSettings(DEFAULT_PRACTICE_SETTINGS);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function decodePracticeSettings(value: unknown): PracticeSettings {
|
|
96
|
+
if (!isRecord(value) || !hasOnlyKeys(value, PRACTICE_KEYS)
|
|
97
|
+
|| value.schemaVersion !== PRACTICE_SCHEMA_VERSION
|
|
98
|
+
|| !isCounter(value.revision)
|
|
99
|
+
|| !isCounter(value.epoch)
|
|
100
|
+
|| typeof value.enabled !== "boolean"
|
|
101
|
+
|| (value.consentedAt !== undefined && !isTimestamp(value.consentedAt))
|
|
102
|
+
|| (value.snoozedUntil !== undefined && !isTimestamp(value.snoozedUntil))) {
|
|
103
|
+
throw new Error("Invalid practice settings");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const targets = canonicalizePracticeTargets(value.targets);
|
|
107
|
+
return {
|
|
108
|
+
schemaVersion: PRACTICE_SCHEMA_VERSION,
|
|
109
|
+
revision: value.revision,
|
|
110
|
+
epoch: value.epoch,
|
|
111
|
+
enabled: value.enabled,
|
|
112
|
+
targets,
|
|
113
|
+
...(value.consentedAt === undefined ? {} : { consentedAt: value.consentedAt }),
|
|
114
|
+
...(value.snoozedUntil === undefined ? {} : { snoozedUntil: value.snoozedUntil }),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function isGloballySnoozed(settings: PracticeSettings, now = Date.now()): boolean {
|
|
119
|
+
return settings.snoozedUntil !== undefined && settings.snoozedUntil > now;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface PracticeSessionSnoozeEntry {
|
|
123
|
+
schemaVersion: typeof PRACTICE_SCHEMA_VERSION;
|
|
124
|
+
epoch: number;
|
|
125
|
+
sessionHash: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface CustomSessionEntryLike {
|
|
129
|
+
type: string;
|
|
130
|
+
customType?: string;
|
|
131
|
+
data?: unknown;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function hashPracticeSessionFile(sessionFile: string): string {
|
|
135
|
+
return createHash("sha256").update(sessionFile).digest("hex");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function decodeSessionSnooze(value: unknown): PracticeSessionSnoozeEntry | undefined {
|
|
139
|
+
if (!isRecord(value) || !hasOnlyKeys(value, SESSION_SNOOZE_KEYS)
|
|
140
|
+
|| value.schemaVersion !== PRACTICE_SCHEMA_VERSION
|
|
141
|
+
|| !isCounter(value.epoch)
|
|
142
|
+
|| typeof value.sessionHash !== "string"
|
|
143
|
+
|| !/^[a-f0-9]{64}$/.test(value.sessionHash)) return undefined;
|
|
144
|
+
return {
|
|
145
|
+
schemaVersion: PRACTICE_SCHEMA_VERSION,
|
|
146
|
+
epoch: value.epoch,
|
|
147
|
+
sessionHash: value.sessionHash,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Runtime owner for durable session-file snooze and ephemeral-session fallback. */
|
|
152
|
+
export class PracticeSessionSnooze {
|
|
153
|
+
private ephemeralEpoch: number | undefined;
|
|
154
|
+
|
|
155
|
+
restore(entries: readonly CustomSessionEntryLike[], sessionFile: string | undefined, epoch: number): boolean {
|
|
156
|
+
if (sessionFile === undefined) return this.ephemeralEpoch === epoch;
|
|
157
|
+
const expectedHash = hashPracticeSessionFile(sessionFile);
|
|
158
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
159
|
+
const entry = entries[index]!;
|
|
160
|
+
if (entry.type !== "custom"
|
|
161
|
+
|| (entry.customType !== PRACTICE_SESSION_ENTRY_TYPE
|
|
162
|
+
&& entry.customType !== PRACTICE_SESSION_RESUME_ENTRY_TYPE)) continue;
|
|
163
|
+
const data = decodeSessionSnooze(entry.data);
|
|
164
|
+
if (data?.epoch !== epoch || data.sessionHash !== expectedHash) continue;
|
|
165
|
+
return entry.customType === PRACTICE_SESSION_ENTRY_TYPE;
|
|
166
|
+
}
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
snooze(
|
|
171
|
+
sessionFile: string | undefined,
|
|
172
|
+
epoch: number,
|
|
173
|
+
appendEntry: (customType: string, data: PracticeSessionSnoozeEntry) => void,
|
|
174
|
+
): void {
|
|
175
|
+
if (sessionFile === undefined) {
|
|
176
|
+
this.ephemeralEpoch = epoch;
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
appendEntry(PRACTICE_SESSION_ENTRY_TYPE, {
|
|
180
|
+
schemaVersion: PRACTICE_SCHEMA_VERSION,
|
|
181
|
+
epoch,
|
|
182
|
+
sessionHash: hashPracticeSessionFile(sessionFile),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
resume(
|
|
187
|
+
sessionFile: string | undefined,
|
|
188
|
+
epoch: number,
|
|
189
|
+
appendEntry: (customType: string, data: PracticeSessionSnoozeEntry) => void,
|
|
190
|
+
): void {
|
|
191
|
+
if (sessionFile === undefined) {
|
|
192
|
+
this.ephemeralEpoch = undefined;
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
appendEntry(PRACTICE_SESSION_RESUME_ENTRY_TYPE, {
|
|
196
|
+
schemaVersion: PRACTICE_SCHEMA_VERSION,
|
|
197
|
+
epoch,
|
|
198
|
+
sessionHash: hashPracticeSessionFile(sessionFile),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -16,7 +16,7 @@ export async function runSetup(
|
|
|
16
16
|
if (!model) return false;
|
|
17
17
|
const approved = await ctx.ui.confirm(
|
|
18
18
|
"Enable Pi Fluency?",
|
|
19
|
-
`User-authored prose will be sent to ${sanitizeTerminalLabel(model.provider, 100) || "unknown-provider"}/${sanitizeTerminalLabel(model.id, 100) || "unknown-model"}. Code, commands, assistant text, and tool output are excluded. Raw prompt bodies are not stored; bounded sanitized excerpts may equal a short prompt.`,
|
|
19
|
+
`User-authored prose will be sent to ${sanitizeTerminalLabel(model.provider, 100) || "unknown-provider"}/${sanitizeTerminalLabel(model.id, 100) || "unknown-model"} for background analytics after Pi Fluency allows a prompt. Code, commands, assistant text, and tool output are excluded. Raw prompt bodies are not stored; bounded sanitized excerpts may equal a short prompt. Optional preflight practice is off and requires a separate disclosure before first activation.`,
|
|
20
20
|
);
|
|
21
21
|
if (!approved) return false;
|
|
22
22
|
await store.updateSettings({
|
|
@@ -9,6 +9,7 @@ export type StatusState =
|
|
|
9
9
|
ratePerThousand: number | undefined;
|
|
10
10
|
}
|
|
11
11
|
| { kind: "initial-loading" }
|
|
12
|
+
| { kind: "practice-check" }
|
|
12
13
|
| { kind: "error"; reason: StatusErrorReason }
|
|
13
14
|
| { kind: "hidden" };
|
|
14
15
|
|
|
@@ -31,6 +32,8 @@ export function formatStatus(state: StatusState): string | undefined {
|
|
|
31
32
|
}
|
|
32
33
|
case "initial-loading":
|
|
33
34
|
return ` … … ${EMPTY_SPARKLINE} —/k`;
|
|
35
|
+
case "practice-check":
|
|
36
|
+
return " CHECK";
|
|
34
37
|
case "error":
|
|
35
38
|
return ` ERR ${state.reason}`;
|
|
36
39
|
case "hidden":
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { appendFile, chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { appendFile, chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { lock, type LockOptions } from "proper-lockfile";
|
|
5
5
|
import {
|
|
6
6
|
DEFAULT_SETTINGS,
|
|
7
7
|
HISTORY_SCHEMA_VERSION,
|
|
8
|
+
PRACTICE_SCHEMA_VERSION,
|
|
8
9
|
SCHEMA_VERSION,
|
|
9
10
|
type AnalysisResult,
|
|
10
11
|
type CollectedPrompt,
|
|
@@ -13,6 +14,9 @@ import {
|
|
|
13
14
|
type FluencySettings,
|
|
14
15
|
type FluencyState,
|
|
15
16
|
type MistakePattern,
|
|
17
|
+
type PracticePolicySnapshot,
|
|
18
|
+
type PracticeSettings,
|
|
19
|
+
type PracticeTarget,
|
|
16
20
|
type ReviewPattern,
|
|
17
21
|
} from "./types.js";
|
|
18
22
|
import {
|
|
@@ -40,6 +44,13 @@ import {
|
|
|
40
44
|
decodeHistoryGenerationMarker,
|
|
41
45
|
encodeHistoryGenerationMarker,
|
|
42
46
|
} from "./generation-marker.js";
|
|
47
|
+
import {
|
|
48
|
+
FIVE_HOURS_MS,
|
|
49
|
+
canonicalizePracticeTargets,
|
|
50
|
+
copyPracticeSettings,
|
|
51
|
+
decodePracticeSettings,
|
|
52
|
+
defaultPracticeSettings,
|
|
53
|
+
} from "./practice-settings.js";
|
|
43
54
|
|
|
44
55
|
const HISTORY_SCHEMA_WARNING = "History migration required; run /fluency clear";
|
|
45
56
|
const HISTORY_GENERATION_FILE = "history-generation";
|
|
@@ -57,6 +68,18 @@ const LOCK_RETRIES = {
|
|
|
57
68
|
|
|
58
69
|
type LockProvider = (file: string, options: LockOptions) => Promise<() => Promise<void>>;
|
|
59
70
|
type FileReplacer = (temporary: string, destination: string) => Promise<void>;
|
|
71
|
+
type PolicyFileReader = (path: string) => Promise<string>;
|
|
72
|
+
|
|
73
|
+
export interface AnalysisCommitFence {
|
|
74
|
+
historyGeneration: string;
|
|
75
|
+
enabled: boolean;
|
|
76
|
+
consentedAt?: number;
|
|
77
|
+
provider?: string;
|
|
78
|
+
modelId?: string;
|
|
79
|
+
minimumConfidence: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type ConditionalAppendResult = "committed" | "generation-stale" | "authorization-stale" | "analyzer-stale";
|
|
60
83
|
|
|
61
84
|
const errantCategorySet = new Set<string>(ERRANT_CATEGORIES);
|
|
62
85
|
|
|
@@ -118,7 +141,9 @@ function decodeSettings(value: unknown): FluencySettings {
|
|
|
118
141
|
export class FluencyStore {
|
|
119
142
|
private static lockProvider: LockProvider = lock;
|
|
120
143
|
private static settingsFileReplacer: FileReplacer = rename;
|
|
144
|
+
private static practiceFileReplacer: FileReplacer = rename;
|
|
121
145
|
private static historyFileReplacer: FileReplacer = rename;
|
|
146
|
+
private static policyFileReader: PolicyFileReader = (path) => readFile(path, "utf8");
|
|
122
147
|
|
|
123
148
|
private readonly state = createFluencyState();
|
|
124
149
|
private readonly warnings: string[] = [];
|
|
@@ -127,12 +152,14 @@ export class FluencyStore {
|
|
|
127
152
|
private eventsSinceCompact = 0;
|
|
128
153
|
private historyResetRequired = false;
|
|
129
154
|
private settings: FluencySettings = copySettings(DEFAULT_SETTINGS);
|
|
155
|
+
private practice: PracticeSettings = defaultPracticeSettings();
|
|
130
156
|
private mutationQueue: Promise<void> = Promise.resolve();
|
|
131
157
|
|
|
132
158
|
private constructor(
|
|
133
159
|
private readonly rootDir: string,
|
|
134
160
|
private readonly historyPath: string,
|
|
135
161
|
private readonly settingsPath: string,
|
|
162
|
+
private readonly practicePath: string,
|
|
136
163
|
private readonly historyGenerationPath: string,
|
|
137
164
|
) {}
|
|
138
165
|
|
|
@@ -143,6 +170,7 @@ export class FluencyStore {
|
|
|
143
170
|
rootDir,
|
|
144
171
|
join(rootDir, "history.jsonl"),
|
|
145
172
|
join(rootDir, "settings.json"),
|
|
173
|
+
join(rootDir, "practice.json"),
|
|
146
174
|
join(rootDir, HISTORY_GENERATION_FILE),
|
|
147
175
|
);
|
|
148
176
|
await store.withGlobalLock(async (signal) => {
|
|
@@ -169,10 +197,22 @@ export class FluencyStore {
|
|
|
169
197
|
|
|
170
198
|
requiresHistoryReset(): boolean { return this.historyResetRequired; }
|
|
171
199
|
getSettings(): FluencySettings { return copySettings(this.settings); }
|
|
200
|
+
getPracticeSettings(): PracticeSettings { return copyPracticeSettings(this.practice); }
|
|
172
201
|
getWarnings(): string[] { return [...this.warnings]; }
|
|
173
202
|
|
|
203
|
+
captureAnalysisCommitFence(settings: FluencySettings = this.settings): AnalysisCommitFence {
|
|
204
|
+
return {
|
|
205
|
+
historyGeneration: this.historyGeneration,
|
|
206
|
+
enabled: settings.enabled,
|
|
207
|
+
minimumConfidence: settings.minimumConfidence,
|
|
208
|
+
...(settings.consentedAt === undefined ? {} : { consentedAt: settings.consentedAt }),
|
|
209
|
+
...(settings.provider === undefined ? {} : { provider: settings.provider }),
|
|
210
|
+
...(settings.modelId === undefined ? {} : { modelId: settings.modelId }),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
174
214
|
private async hardenExistingFiles(): Promise<void> {
|
|
175
|
-
for (const path of [this.historyPath, this.settingsPath, this.historyGenerationPath]) {
|
|
215
|
+
for (const path of [this.historyPath, this.settingsPath, this.practicePath, this.historyGenerationPath]) {
|
|
176
216
|
try {
|
|
177
217
|
await chmod(path, PRIVATE_FILE_MODE);
|
|
178
218
|
} catch (error) {
|
|
@@ -294,6 +334,18 @@ export class FluencyStore {
|
|
|
294
334
|
}
|
|
295
335
|
}
|
|
296
336
|
|
|
337
|
+
try {
|
|
338
|
+
const parsed = JSON.parse(await readFile(this.practicePath, { encoding: "utf8", signal })) as unknown;
|
|
339
|
+
signal.throwIfAborted();
|
|
340
|
+
this.practice = decodePracticeSettings(parsed);
|
|
341
|
+
} catch (error) {
|
|
342
|
+
signal.throwIfAborted();
|
|
343
|
+
this.practice = defaultPracticeSettings();
|
|
344
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT" && recordWarnings) {
|
|
345
|
+
this.warnings.push("Could not read practice settings; defaults loaded");
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
297
349
|
let history: string;
|
|
298
350
|
try {
|
|
299
351
|
history = await readFile(this.historyPath, { encoding: "utf8", signal });
|
|
@@ -423,6 +475,204 @@ export class FluencyStore {
|
|
|
423
475
|
}));
|
|
424
476
|
}
|
|
425
477
|
|
|
478
|
+
/** Read one stable authorization/configuration/generation snapshot without taking mutation lock. */
|
|
479
|
+
async getFreshPolicySnapshot(deadline: number, signal?: AbortSignal): Promise<PracticePolicySnapshot> {
|
|
480
|
+
if (!Number.isFinite(deadline)) throw new Error("Invalid practice policy deadline");
|
|
481
|
+
const deadlineError = (): Error => new Error("Practice policy read deadline exceeded");
|
|
482
|
+
const abortError = (): unknown => signal?.reason ?? new DOMException("Aborted", "AbortError");
|
|
483
|
+
const readOptional = async (path: string): Promise<string | undefined> => {
|
|
484
|
+
signal?.throwIfAborted();
|
|
485
|
+
const remaining = deadline - Date.now();
|
|
486
|
+
if (remaining <= 0) throw deadlineError();
|
|
487
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
488
|
+
let onAbort: (() => void) | undefined;
|
|
489
|
+
try {
|
|
490
|
+
const cancelled = signal === undefined
|
|
491
|
+
? new Promise<never>(() => undefined)
|
|
492
|
+
: new Promise<never>((_resolve, reject) => {
|
|
493
|
+
onAbort = () => reject(abortError());
|
|
494
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
495
|
+
if (signal.aborted) onAbort();
|
|
496
|
+
});
|
|
497
|
+
const value = await Promise.race([
|
|
498
|
+
FluencyStore.policyFileReader(path),
|
|
499
|
+
new Promise<never>((_resolve, reject) => { timer = setTimeout(() => reject(deadlineError()), remaining); }),
|
|
500
|
+
cancelled,
|
|
501
|
+
]);
|
|
502
|
+
signal?.throwIfAborted();
|
|
503
|
+
if (Date.now() >= deadline) throw deadlineError();
|
|
504
|
+
return value;
|
|
505
|
+
} catch (error) {
|
|
506
|
+
if (signal?.aborted) throw abortError();
|
|
507
|
+
if (Date.now() >= deadline || (error instanceof Error && error.message === deadlineError().message)) {
|
|
508
|
+
throw deadlineError();
|
|
509
|
+
}
|
|
510
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
511
|
+
throw error;
|
|
512
|
+
} finally {
|
|
513
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
514
|
+
if (onAbort !== undefined) signal?.removeEventListener("abort", onAbort);
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
while (Date.now() < deadline) {
|
|
519
|
+
signal?.throwIfAborted();
|
|
520
|
+
const settingsFirst = await readOptional(this.settingsPath);
|
|
521
|
+
const practiceFirst = await readOptional(this.practicePath);
|
|
522
|
+
const generationFirst = await readOptional(this.historyGenerationPath);
|
|
523
|
+
const settingsSecond = await readOptional(this.settingsPath);
|
|
524
|
+
const practiceSecond = await readOptional(this.practicePath);
|
|
525
|
+
const generationSecond = await readOptional(this.historyGenerationPath);
|
|
526
|
+
if (settingsFirst !== settingsSecond || practiceFirst !== practiceSecond || generationFirst !== generationSecond) continue;
|
|
527
|
+
|
|
528
|
+
let settings = copySettings(DEFAULT_SETTINGS);
|
|
529
|
+
let practice = defaultPracticeSettings();
|
|
530
|
+
try {
|
|
531
|
+
if (settingsSecond !== undefined) settings = decodeSettings(JSON.parse(settingsSecond) as unknown);
|
|
532
|
+
} catch { /* Corrupt complete file has safe effective defaults. */ }
|
|
533
|
+
try {
|
|
534
|
+
if (practiceSecond !== undefined) practice = decodePracticeSettings(JSON.parse(practiceSecond) as unknown);
|
|
535
|
+
} catch { /* Corrupt complete file has safe effective defaults. */ }
|
|
536
|
+
if (generationSecond === undefined) throw new Error("History generation unavailable");
|
|
537
|
+
const marker = decodeHistoryGenerationMarker(generationSecond);
|
|
538
|
+
if (marker.resetPending) throw new Error("History clear in progress");
|
|
539
|
+
return {
|
|
540
|
+
settings: copySettings(settings),
|
|
541
|
+
practice: copyPracticeSettings(practice),
|
|
542
|
+
historyGeneration: marker.generation,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
throw deadlineError();
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
private updatePractice(
|
|
549
|
+
mutator: (practice: PracticeSettings) => PracticeSettings,
|
|
550
|
+
): Promise<void> {
|
|
551
|
+
return this.enqueueMutation(async (signal) => {
|
|
552
|
+
await this.savePracticeUnsafe(mutator(copyPracticeSettings(this.practice)), signal);
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
recordPracticeConsent(consentedAt: number): Promise<void> {
|
|
557
|
+
return this.updatePractice((practice) => ({
|
|
558
|
+
...practice,
|
|
559
|
+
revision: practice.revision + 1,
|
|
560
|
+
consentedAt,
|
|
561
|
+
}));
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Atomically records first-use consent, optional first target, and enabled state. */
|
|
565
|
+
activatePractice(consentedAt: number, firstTarget?: PracticeTarget): Promise<void> {
|
|
566
|
+
if (!Number.isFinite(consentedAt) || consentedAt < 0) {
|
|
567
|
+
return Promise.reject(new Error("Invalid practice consent"));
|
|
568
|
+
}
|
|
569
|
+
let canonicalTarget: PracticeTarget | undefined;
|
|
570
|
+
try {
|
|
571
|
+
canonicalTarget = firstTarget === undefined
|
|
572
|
+
? undefined
|
|
573
|
+
: canonicalizePracticeTargets([firstTarget])[0]!;
|
|
574
|
+
} catch (error) {
|
|
575
|
+
return Promise.reject(error);
|
|
576
|
+
}
|
|
577
|
+
return this.updatePractice((practice) => {
|
|
578
|
+
const remaining = canonicalTarget === undefined
|
|
579
|
+
? practice.targets
|
|
580
|
+
: practice.targets.filter((item) => item.explanation !== canonicalTarget.explanation);
|
|
581
|
+
const targets = canonicalTarget === undefined
|
|
582
|
+
? remaining
|
|
583
|
+
: canonicalizePracticeTargets([...remaining, canonicalTarget]);
|
|
584
|
+
return {
|
|
585
|
+
...practice,
|
|
586
|
+
revision: practice.revision + 1,
|
|
587
|
+
consentedAt,
|
|
588
|
+
enabled: true,
|
|
589
|
+
targets,
|
|
590
|
+
};
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
setPracticeEnabled(enabled: boolean): Promise<void> {
|
|
595
|
+
return this.updatePractice((practice) => ({
|
|
596
|
+
...practice,
|
|
597
|
+
revision: practice.revision + 1,
|
|
598
|
+
enabled,
|
|
599
|
+
}));
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
setPracticeTarget(target: PracticeTarget, selected: boolean): Promise<void> {
|
|
603
|
+
let canonicalTarget: PracticeTarget;
|
|
604
|
+
try {
|
|
605
|
+
canonicalTarget = canonicalizePracticeTargets([target])[0]!;
|
|
606
|
+
} catch (error) {
|
|
607
|
+
return Promise.reject(error);
|
|
608
|
+
}
|
|
609
|
+
return this.updatePractice((practice) => {
|
|
610
|
+
const remaining = practice.targets.filter((item) => item.explanation !== canonicalTarget.explanation);
|
|
611
|
+
const targets = canonicalizePracticeTargets(selected ? [...remaining, canonicalTarget] : remaining);
|
|
612
|
+
return { ...practice, revision: practice.revision + 1, targets };
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Revision/deadline-fenced modal action. False means no sidecar replacement occurred. */
|
|
617
|
+
snoozePracticeForFiveHours(
|
|
618
|
+
expectedRevision: number,
|
|
619
|
+
operationDeadline: number,
|
|
620
|
+
now = Date.now(),
|
|
621
|
+
): Promise<boolean> {
|
|
622
|
+
if (!Number.isFinite(operationDeadline) || !Number.isFinite(now) || now < 0) {
|
|
623
|
+
return Promise.reject(new Error("Invalid practice snooze"));
|
|
624
|
+
}
|
|
625
|
+
return this.enqueueMutation(async (signal) => {
|
|
626
|
+
if (Date.now() >= operationDeadline || this.practice.revision !== expectedRevision) return false;
|
|
627
|
+
return this.savePracticeUnsafe({
|
|
628
|
+
...this.practice,
|
|
629
|
+
revision: this.practice.revision + 1,
|
|
630
|
+
snoozedUntil: now + FIVE_HOURS_MS,
|
|
631
|
+
}, signal, operationDeadline);
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
resumePractice(): Promise<void> {
|
|
636
|
+
return this.updatePractice((practice) => {
|
|
637
|
+
const { snoozedUntil: _snoozedUntil, ...rest } = practice;
|
|
638
|
+
return { ...rest, revision: practice.revision + 1 };
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
resetPractice(): Promise<void> {
|
|
643
|
+
return this.updatePractice((practice) => ({
|
|
644
|
+
schemaVersion: PRACTICE_SCHEMA_VERSION,
|
|
645
|
+
revision: practice.revision + 1,
|
|
646
|
+
epoch: practice.epoch + 1,
|
|
647
|
+
enabled: false,
|
|
648
|
+
targets: [],
|
|
649
|
+
}));
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
private async savePracticeUnsafe(
|
|
653
|
+
practice: PracticeSettings,
|
|
654
|
+
signal: AbortSignal,
|
|
655
|
+
operationDeadline?: number,
|
|
656
|
+
): Promise<boolean> {
|
|
657
|
+
const copied = decodePracticeSettings(practice);
|
|
658
|
+
const temporary = `${this.practicePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
659
|
+
signal.throwIfAborted();
|
|
660
|
+
await writeFile(temporary, `${JSON.stringify(copied, null, 2)}\n`, {
|
|
661
|
+
encoding: "utf8",
|
|
662
|
+
mode: PRIVATE_FILE_MODE,
|
|
663
|
+
signal,
|
|
664
|
+
});
|
|
665
|
+
signal.throwIfAborted();
|
|
666
|
+
if (operationDeadline !== undefined && Date.now() >= operationDeadline) {
|
|
667
|
+
await rm(temporary, { force: true });
|
|
668
|
+
return false;
|
|
669
|
+
}
|
|
670
|
+
await FluencyStore.practiceFileReplacer(temporary, this.practicePath);
|
|
671
|
+
signal.throwIfAborted();
|
|
672
|
+
this.practice = copied;
|
|
673
|
+
return true;
|
|
674
|
+
}
|
|
675
|
+
|
|
426
676
|
updateSettings(
|
|
427
677
|
patchOrMutator: Partial<FluencySettings> | ((settings: FluencySettings) => Partial<FluencySettings>),
|
|
428
678
|
): Promise<void> {
|
|
@@ -455,24 +705,27 @@ export class FluencyStore {
|
|
|
455
705
|
this.settings = copied;
|
|
456
706
|
}
|
|
457
707
|
|
|
458
|
-
|
|
708
|
+
private analysisEvent(prompt: CollectedPrompt, result: AnalysisResult): FluencyEvent {
|
|
459
709
|
const copiedPrompt: CollectedPrompt = {
|
|
460
710
|
promptHash: prompt.promptHash,
|
|
461
711
|
prose: prompt.prose,
|
|
462
712
|
observedAt: prompt.observedAt,
|
|
463
713
|
};
|
|
714
|
+
if (result.schemaVersion !== 3) throw new Error("Invalid schema-v4 history event");
|
|
715
|
+
return decodeHistoryLine({
|
|
716
|
+
schemaVersion: HISTORY_SCHEMA_VERSION,
|
|
717
|
+
type: "analysis",
|
|
718
|
+
at: copiedPrompt.observedAt,
|
|
719
|
+
prompt: copiedPrompt,
|
|
720
|
+
wordCount: countEnglishWords(copiedPrompt.prose),
|
|
721
|
+
result: copyAnalysisResult(result),
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
appendAnalysis(prompt: CollectedPrompt, result: AnalysisResult): Promise<void> {
|
|
464
726
|
let event: FluencyEvent;
|
|
465
727
|
try {
|
|
466
|
-
|
|
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
|
-
});
|
|
728
|
+
event = this.analysisEvent(prompt, result);
|
|
476
729
|
} catch (error) {
|
|
477
730
|
return Promise.reject(error);
|
|
478
731
|
}
|
|
@@ -482,6 +735,34 @@ export class FluencyStore {
|
|
|
482
735
|
});
|
|
483
736
|
}
|
|
484
737
|
|
|
738
|
+
/** Append precomputed result only while clear generation and analyzer authorization remain exact. */
|
|
739
|
+
conditionalAppendAnalysis(
|
|
740
|
+
fence: AnalysisCommitFence,
|
|
741
|
+
prompt: CollectedPrompt,
|
|
742
|
+
result: AnalysisResult,
|
|
743
|
+
): Promise<ConditionalAppendResult> {
|
|
744
|
+
let event: FluencyEvent;
|
|
745
|
+
try {
|
|
746
|
+
event = this.analysisEvent(prompt, result);
|
|
747
|
+
} catch (error) {
|
|
748
|
+
return Promise.reject(error);
|
|
749
|
+
}
|
|
750
|
+
return this.enqueueMutation(async (signal) => {
|
|
751
|
+
if (this.historyGeneration !== fence.historyGeneration) return "generation-stale";
|
|
752
|
+
const current = this.settings;
|
|
753
|
+
if (!current.enabled
|
|
754
|
+
|| current.consentedAt === undefined
|
|
755
|
+
|| current.enabled !== fence.enabled
|
|
756
|
+
|| current.consentedAt !== fence.consentedAt) return "authorization-stale";
|
|
757
|
+
if (current.provider !== fence.provider
|
|
758
|
+
|| current.modelId !== fence.modelId
|
|
759
|
+
|| current.minimumConfidence !== fence.minimumConfidence) return "analyzer-stale";
|
|
760
|
+
this.assertHistoryReady();
|
|
761
|
+
await this.appendUnsafe(event, signal);
|
|
762
|
+
return "committed";
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
|
|
485
766
|
private reviewPatternBatch(
|
|
486
767
|
patternId: string,
|
|
487
768
|
decision: "accepted" | "dismissed",
|
|
@@ -4,6 +4,7 @@ export const SETTINGS_SCHEMA_VERSION = 3 as const;
|
|
|
4
4
|
export const HISTORY_SCHEMA_VERSION = 4 as const;
|
|
5
5
|
export const SCHEMA_VERSION = SETTINGS_SCHEMA_VERSION;
|
|
6
6
|
export const ANALYSIS_SCHEMA_VERSION = 3 as const;
|
|
7
|
+
export const PRACTICE_SCHEMA_VERSION = 1 as const;
|
|
7
8
|
|
|
8
9
|
export interface CollectedPrompt {
|
|
9
10
|
promptHash: string;
|
|
@@ -123,6 +124,55 @@ export const DEFAULT_SETTINGS: FluencySettings = {
|
|
|
123
124
|
ignoredCategories: [],
|
|
124
125
|
};
|
|
125
126
|
|
|
127
|
+
export interface PracticeTarget {
|
|
128
|
+
explanation: string;
|
|
129
|
+
memberPatternKeys: string[];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface ResolvedPracticeTarget extends PracticeTarget {
|
|
133
|
+
/** Deterministic UI identity derived at runtime; never persisted or rendered. */
|
|
134
|
+
rowKey: string;
|
|
135
|
+
currentPatternKeys: string[];
|
|
136
|
+
coachingEnabled: boolean;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface PracticeMistakeCandidate {
|
|
140
|
+
patternKey: string;
|
|
141
|
+
explanation: string;
|
|
142
|
+
errorType: ErrantErrorType;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface PracticeAnalysisContext {
|
|
146
|
+
/** Complete durable descriptors, independent of bounded analyzer pattern context. */
|
|
147
|
+
targetDescriptors: PracticeTarget[];
|
|
148
|
+
patterns: MistakePattern[];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface PracticeSettings {
|
|
152
|
+
schemaVersion: typeof PRACTICE_SCHEMA_VERSION;
|
|
153
|
+
revision: number;
|
|
154
|
+
epoch: number;
|
|
155
|
+
enabled: boolean;
|
|
156
|
+
consentedAt?: number;
|
|
157
|
+
targets: PracticeTarget[];
|
|
158
|
+
snoozedUntil?: number;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface PracticePolicySnapshot {
|
|
162
|
+
settings: FluencySettings;
|
|
163
|
+
practice: PracticeSettings;
|
|
164
|
+
/** Fresh durable history generation captured with authorization/configuration. */
|
|
165
|
+
historyGeneration: string;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export const DEFAULT_PRACTICE_SETTINGS: PracticeSettings = {
|
|
169
|
+
schemaVersion: PRACTICE_SCHEMA_VERSION,
|
|
170
|
+
revision: 0,
|
|
171
|
+
epoch: 0,
|
|
172
|
+
enabled: false,
|
|
173
|
+
targets: [],
|
|
174
|
+
};
|
|
175
|
+
|
|
126
176
|
type HistoryEventBase = { schemaVersion: typeof HISTORY_SCHEMA_VERSION; at: number };
|
|
127
177
|
|
|
128
178
|
export type FluencyEvent =
|