pi-fluency 0.1.3 → 0.2.1
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 +299 -15
- 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,12 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { mkdir as mkdirCallback, realpath, rmdir, rmdirSync, stat, utimes } from "node:fs";
|
|
3
|
+
import { appendFile, chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { lock, type LockOptions } from "proper-lockfile";
|
|
5
6
|
import {
|
|
6
7
|
DEFAULT_SETTINGS,
|
|
7
8
|
HISTORY_SCHEMA_VERSION,
|
|
9
|
+
PRACTICE_SCHEMA_VERSION,
|
|
8
10
|
SCHEMA_VERSION,
|
|
9
11
|
type AnalysisResult,
|
|
10
12
|
type CollectedPrompt,
|
|
@@ -13,6 +15,9 @@ import {
|
|
|
13
15
|
type FluencySettings,
|
|
14
16
|
type FluencyState,
|
|
15
17
|
type MistakePattern,
|
|
18
|
+
type PracticePolicySnapshot,
|
|
19
|
+
type PracticeSettings,
|
|
20
|
+
type PracticeTarget,
|
|
16
21
|
type ReviewPattern,
|
|
17
22
|
} from "./types.js";
|
|
18
23
|
import {
|
|
@@ -40,7 +45,15 @@ import {
|
|
|
40
45
|
decodeHistoryGenerationMarker,
|
|
41
46
|
encodeHistoryGenerationMarker,
|
|
42
47
|
} from "./generation-marker.js";
|
|
43
|
-
|
|
48
|
+
import {
|
|
49
|
+
FIVE_HOURS_MS,
|
|
50
|
+
canonicalizePracticeTargets,
|
|
51
|
+
copyPracticeSettings,
|
|
52
|
+
decodePracticeSettings,
|
|
53
|
+
defaultPracticeSettings,
|
|
54
|
+
} from "./practice-settings.js";
|
|
55
|
+
|
|
56
|
+
const lockFs = { mkdir: mkdirCallback, realpath, rmdir, rmdirSync, stat, utimes };
|
|
44
57
|
const HISTORY_SCHEMA_WARNING = "History migration required; run /fluency clear";
|
|
45
58
|
const HISTORY_GENERATION_FILE = "history-generation";
|
|
46
59
|
const PRIVATE_DIRECTORY_MODE = 0o700;
|
|
@@ -57,6 +70,18 @@ const LOCK_RETRIES = {
|
|
|
57
70
|
|
|
58
71
|
type LockProvider = (file: string, options: LockOptions) => Promise<() => Promise<void>>;
|
|
59
72
|
type FileReplacer = (temporary: string, destination: string) => Promise<void>;
|
|
73
|
+
type PolicyFileReader = (path: string) => Promise<string>;
|
|
74
|
+
|
|
75
|
+
export interface AnalysisCommitFence {
|
|
76
|
+
historyGeneration: string;
|
|
77
|
+
enabled: boolean;
|
|
78
|
+
consentedAt?: number;
|
|
79
|
+
provider?: string;
|
|
80
|
+
modelId?: string;
|
|
81
|
+
minimumConfidence: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type ConditionalAppendResult = "committed" | "generation-stale" | "authorization-stale" | "analyzer-stale";
|
|
60
85
|
|
|
61
86
|
const errantCategorySet = new Set<string>(ERRANT_CATEGORIES);
|
|
62
87
|
|
|
@@ -116,9 +141,12 @@ function decodeSettings(value: unknown): FluencySettings {
|
|
|
116
141
|
}
|
|
117
142
|
|
|
118
143
|
export class FluencyStore {
|
|
119
|
-
private static lockProvider: LockProvider =
|
|
144
|
+
private static lockProvider: LockProvider = (file, options) =>
|
|
145
|
+
lock(file, { ...options, fs: lockFs });
|
|
120
146
|
private static settingsFileReplacer: FileReplacer = rename;
|
|
147
|
+
private static practiceFileReplacer: FileReplacer = rename;
|
|
121
148
|
private static historyFileReplacer: FileReplacer = rename;
|
|
149
|
+
private static policyFileReader: PolicyFileReader = (path) => readFile(path, "utf8");
|
|
122
150
|
|
|
123
151
|
private readonly state = createFluencyState();
|
|
124
152
|
private readonly warnings: string[] = [];
|
|
@@ -127,12 +155,14 @@ export class FluencyStore {
|
|
|
127
155
|
private eventsSinceCompact = 0;
|
|
128
156
|
private historyResetRequired = false;
|
|
129
157
|
private settings: FluencySettings = copySettings(DEFAULT_SETTINGS);
|
|
158
|
+
private practice: PracticeSettings = defaultPracticeSettings();
|
|
130
159
|
private mutationQueue: Promise<void> = Promise.resolve();
|
|
131
160
|
|
|
132
161
|
private constructor(
|
|
133
162
|
private readonly rootDir: string,
|
|
134
163
|
private readonly historyPath: string,
|
|
135
164
|
private readonly settingsPath: string,
|
|
165
|
+
private readonly practicePath: string,
|
|
136
166
|
private readonly historyGenerationPath: string,
|
|
137
167
|
) {}
|
|
138
168
|
|
|
@@ -143,6 +173,7 @@ export class FluencyStore {
|
|
|
143
173
|
rootDir,
|
|
144
174
|
join(rootDir, "history.jsonl"),
|
|
145
175
|
join(rootDir, "settings.json"),
|
|
176
|
+
join(rootDir, "practice.json"),
|
|
146
177
|
join(rootDir, HISTORY_GENERATION_FILE),
|
|
147
178
|
);
|
|
148
179
|
await store.withGlobalLock(async (signal) => {
|
|
@@ -169,10 +200,22 @@ export class FluencyStore {
|
|
|
169
200
|
|
|
170
201
|
requiresHistoryReset(): boolean { return this.historyResetRequired; }
|
|
171
202
|
getSettings(): FluencySettings { return copySettings(this.settings); }
|
|
203
|
+
getPracticeSettings(): PracticeSettings { return copyPracticeSettings(this.practice); }
|
|
172
204
|
getWarnings(): string[] { return [...this.warnings]; }
|
|
173
205
|
|
|
206
|
+
captureAnalysisCommitFence(settings: FluencySettings = this.settings): AnalysisCommitFence {
|
|
207
|
+
return {
|
|
208
|
+
historyGeneration: this.historyGeneration,
|
|
209
|
+
enabled: settings.enabled,
|
|
210
|
+
minimumConfidence: settings.minimumConfidence,
|
|
211
|
+
...(settings.consentedAt === undefined ? {} : { consentedAt: settings.consentedAt }),
|
|
212
|
+
...(settings.provider === undefined ? {} : { provider: settings.provider }),
|
|
213
|
+
...(settings.modelId === undefined ? {} : { modelId: settings.modelId }),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
174
217
|
private async hardenExistingFiles(): Promise<void> {
|
|
175
|
-
for (const path of [this.historyPath, this.settingsPath, this.historyGenerationPath]) {
|
|
218
|
+
for (const path of [this.historyPath, this.settingsPath, this.practicePath, this.historyGenerationPath]) {
|
|
176
219
|
try {
|
|
177
220
|
await chmod(path, PRIVATE_FILE_MODE);
|
|
178
221
|
} catch (error) {
|
|
@@ -294,6 +337,18 @@ export class FluencyStore {
|
|
|
294
337
|
}
|
|
295
338
|
}
|
|
296
339
|
|
|
340
|
+
try {
|
|
341
|
+
const parsed = JSON.parse(await readFile(this.practicePath, { encoding: "utf8", signal })) as unknown;
|
|
342
|
+
signal.throwIfAborted();
|
|
343
|
+
this.practice = decodePracticeSettings(parsed);
|
|
344
|
+
} catch (error) {
|
|
345
|
+
signal.throwIfAborted();
|
|
346
|
+
this.practice = defaultPracticeSettings();
|
|
347
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT" && recordWarnings) {
|
|
348
|
+
this.warnings.push("Could not read practice settings; defaults loaded");
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
297
352
|
let history: string;
|
|
298
353
|
try {
|
|
299
354
|
history = await readFile(this.historyPath, { encoding: "utf8", signal });
|
|
@@ -423,6 +478,204 @@ export class FluencyStore {
|
|
|
423
478
|
}));
|
|
424
479
|
}
|
|
425
480
|
|
|
481
|
+
/** Read one stable authorization/configuration/generation snapshot without taking mutation lock. */
|
|
482
|
+
async getFreshPolicySnapshot(deadline: number, signal?: AbortSignal): Promise<PracticePolicySnapshot> {
|
|
483
|
+
if (!Number.isFinite(deadline)) throw new Error("Invalid practice policy deadline");
|
|
484
|
+
const deadlineError = (): Error => new Error("Practice policy read deadline exceeded");
|
|
485
|
+
const abortError = (): unknown => signal?.reason ?? new DOMException("Aborted", "AbortError");
|
|
486
|
+
const readOptional = async (path: string): Promise<string | undefined> => {
|
|
487
|
+
signal?.throwIfAborted();
|
|
488
|
+
const remaining = deadline - Date.now();
|
|
489
|
+
if (remaining <= 0) throw deadlineError();
|
|
490
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
491
|
+
let onAbort: (() => void) | undefined;
|
|
492
|
+
try {
|
|
493
|
+
const cancelled = signal === undefined
|
|
494
|
+
? new Promise<never>(() => undefined)
|
|
495
|
+
: new Promise<never>((_resolve, reject) => {
|
|
496
|
+
onAbort = () => reject(abortError());
|
|
497
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
498
|
+
if (signal.aborted) onAbort();
|
|
499
|
+
});
|
|
500
|
+
const value = await Promise.race([
|
|
501
|
+
FluencyStore.policyFileReader(path),
|
|
502
|
+
new Promise<never>((_resolve, reject) => { timer = setTimeout(() => reject(deadlineError()), remaining); }),
|
|
503
|
+
cancelled,
|
|
504
|
+
]);
|
|
505
|
+
signal?.throwIfAborted();
|
|
506
|
+
if (Date.now() >= deadline) throw deadlineError();
|
|
507
|
+
return value;
|
|
508
|
+
} catch (error) {
|
|
509
|
+
if (signal?.aborted) throw abortError();
|
|
510
|
+
if (Date.now() >= deadline || (error instanceof Error && error.message === deadlineError().message)) {
|
|
511
|
+
throw deadlineError();
|
|
512
|
+
}
|
|
513
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
514
|
+
throw error;
|
|
515
|
+
} finally {
|
|
516
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
517
|
+
if (onAbort !== undefined) signal?.removeEventListener("abort", onAbort);
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
while (Date.now() < deadline) {
|
|
522
|
+
signal?.throwIfAborted();
|
|
523
|
+
const settingsFirst = await readOptional(this.settingsPath);
|
|
524
|
+
const practiceFirst = await readOptional(this.practicePath);
|
|
525
|
+
const generationFirst = await readOptional(this.historyGenerationPath);
|
|
526
|
+
const settingsSecond = await readOptional(this.settingsPath);
|
|
527
|
+
const practiceSecond = await readOptional(this.practicePath);
|
|
528
|
+
const generationSecond = await readOptional(this.historyGenerationPath);
|
|
529
|
+
if (settingsFirst !== settingsSecond || practiceFirst !== practiceSecond || generationFirst !== generationSecond) continue;
|
|
530
|
+
|
|
531
|
+
let settings = copySettings(DEFAULT_SETTINGS);
|
|
532
|
+
let practice = defaultPracticeSettings();
|
|
533
|
+
try {
|
|
534
|
+
if (settingsSecond !== undefined) settings = decodeSettings(JSON.parse(settingsSecond) as unknown);
|
|
535
|
+
} catch { /* Corrupt complete file has safe effective defaults. */ }
|
|
536
|
+
try {
|
|
537
|
+
if (practiceSecond !== undefined) practice = decodePracticeSettings(JSON.parse(practiceSecond) as unknown);
|
|
538
|
+
} catch { /* Corrupt complete file has safe effective defaults. */ }
|
|
539
|
+
if (generationSecond === undefined) throw new Error("History generation unavailable");
|
|
540
|
+
const marker = decodeHistoryGenerationMarker(generationSecond);
|
|
541
|
+
if (marker.resetPending) throw new Error("History clear in progress");
|
|
542
|
+
return {
|
|
543
|
+
settings: copySettings(settings),
|
|
544
|
+
practice: copyPracticeSettings(practice),
|
|
545
|
+
historyGeneration: marker.generation,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
throw deadlineError();
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
private updatePractice(
|
|
552
|
+
mutator: (practice: PracticeSettings) => PracticeSettings,
|
|
553
|
+
): Promise<void> {
|
|
554
|
+
return this.enqueueMutation(async (signal) => {
|
|
555
|
+
await this.savePracticeUnsafe(mutator(copyPracticeSettings(this.practice)), signal);
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
recordPracticeConsent(consentedAt: number): Promise<void> {
|
|
560
|
+
return this.updatePractice((practice) => ({
|
|
561
|
+
...practice,
|
|
562
|
+
revision: practice.revision + 1,
|
|
563
|
+
consentedAt,
|
|
564
|
+
}));
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Atomically records first-use consent, optional first target, and enabled state. */
|
|
568
|
+
activatePractice(consentedAt: number, firstTarget?: PracticeTarget): Promise<void> {
|
|
569
|
+
if (!Number.isFinite(consentedAt) || consentedAt < 0) {
|
|
570
|
+
return Promise.reject(new Error("Invalid practice consent"));
|
|
571
|
+
}
|
|
572
|
+
let canonicalTarget: PracticeTarget | undefined;
|
|
573
|
+
try {
|
|
574
|
+
canonicalTarget = firstTarget === undefined
|
|
575
|
+
? undefined
|
|
576
|
+
: canonicalizePracticeTargets([firstTarget])[0]!;
|
|
577
|
+
} catch (error) {
|
|
578
|
+
return Promise.reject(error);
|
|
579
|
+
}
|
|
580
|
+
return this.updatePractice((practice) => {
|
|
581
|
+
const remaining = canonicalTarget === undefined
|
|
582
|
+
? practice.targets
|
|
583
|
+
: practice.targets.filter((item) => item.explanation !== canonicalTarget.explanation);
|
|
584
|
+
const targets = canonicalTarget === undefined
|
|
585
|
+
? remaining
|
|
586
|
+
: canonicalizePracticeTargets([...remaining, canonicalTarget]);
|
|
587
|
+
return {
|
|
588
|
+
...practice,
|
|
589
|
+
revision: practice.revision + 1,
|
|
590
|
+
consentedAt,
|
|
591
|
+
enabled: true,
|
|
592
|
+
targets,
|
|
593
|
+
};
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
setPracticeEnabled(enabled: boolean): Promise<void> {
|
|
598
|
+
return this.updatePractice((practice) => ({
|
|
599
|
+
...practice,
|
|
600
|
+
revision: practice.revision + 1,
|
|
601
|
+
enabled,
|
|
602
|
+
}));
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
setPracticeTarget(target: PracticeTarget, selected: boolean): Promise<void> {
|
|
606
|
+
let canonicalTarget: PracticeTarget;
|
|
607
|
+
try {
|
|
608
|
+
canonicalTarget = canonicalizePracticeTargets([target])[0]!;
|
|
609
|
+
} catch (error) {
|
|
610
|
+
return Promise.reject(error);
|
|
611
|
+
}
|
|
612
|
+
return this.updatePractice((practice) => {
|
|
613
|
+
const remaining = practice.targets.filter((item) => item.explanation !== canonicalTarget.explanation);
|
|
614
|
+
const targets = canonicalizePracticeTargets(selected ? [...remaining, canonicalTarget] : remaining);
|
|
615
|
+
return { ...practice, revision: practice.revision + 1, targets };
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** Revision/deadline-fenced modal action. False means no sidecar replacement occurred. */
|
|
620
|
+
snoozePracticeForFiveHours(
|
|
621
|
+
expectedRevision: number,
|
|
622
|
+
operationDeadline: number,
|
|
623
|
+
now = Date.now(),
|
|
624
|
+
): Promise<boolean> {
|
|
625
|
+
if (!Number.isFinite(operationDeadline) || !Number.isFinite(now) || now < 0) {
|
|
626
|
+
return Promise.reject(new Error("Invalid practice snooze"));
|
|
627
|
+
}
|
|
628
|
+
return this.enqueueMutation(async (signal) => {
|
|
629
|
+
if (Date.now() >= operationDeadline || this.practice.revision !== expectedRevision) return false;
|
|
630
|
+
return this.savePracticeUnsafe({
|
|
631
|
+
...this.practice,
|
|
632
|
+
revision: this.practice.revision + 1,
|
|
633
|
+
snoozedUntil: now + FIVE_HOURS_MS,
|
|
634
|
+
}, signal, operationDeadline);
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
resumePractice(): Promise<void> {
|
|
639
|
+
return this.updatePractice((practice) => {
|
|
640
|
+
const { snoozedUntil: _snoozedUntil, ...rest } = practice;
|
|
641
|
+
return { ...rest, revision: practice.revision + 1 };
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
resetPractice(): Promise<void> {
|
|
646
|
+
return this.updatePractice((practice) => ({
|
|
647
|
+
schemaVersion: PRACTICE_SCHEMA_VERSION,
|
|
648
|
+
revision: practice.revision + 1,
|
|
649
|
+
epoch: practice.epoch + 1,
|
|
650
|
+
enabled: false,
|
|
651
|
+
targets: [],
|
|
652
|
+
}));
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
private async savePracticeUnsafe(
|
|
656
|
+
practice: PracticeSettings,
|
|
657
|
+
signal: AbortSignal,
|
|
658
|
+
operationDeadline?: number,
|
|
659
|
+
): Promise<boolean> {
|
|
660
|
+
const copied = decodePracticeSettings(practice);
|
|
661
|
+
const temporary = `${this.practicePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
662
|
+
signal.throwIfAborted();
|
|
663
|
+
await writeFile(temporary, `${JSON.stringify(copied, null, 2)}\n`, {
|
|
664
|
+
encoding: "utf8",
|
|
665
|
+
mode: PRIVATE_FILE_MODE,
|
|
666
|
+
signal,
|
|
667
|
+
});
|
|
668
|
+
signal.throwIfAborted();
|
|
669
|
+
if (operationDeadline !== undefined && Date.now() >= operationDeadline) {
|
|
670
|
+
await rm(temporary, { force: true });
|
|
671
|
+
return false;
|
|
672
|
+
}
|
|
673
|
+
await FluencyStore.practiceFileReplacer(temporary, this.practicePath);
|
|
674
|
+
signal.throwIfAborted();
|
|
675
|
+
this.practice = copied;
|
|
676
|
+
return true;
|
|
677
|
+
}
|
|
678
|
+
|
|
426
679
|
updateSettings(
|
|
427
680
|
patchOrMutator: Partial<FluencySettings> | ((settings: FluencySettings) => Partial<FluencySettings>),
|
|
428
681
|
): Promise<void> {
|
|
@@ -455,24 +708,27 @@ export class FluencyStore {
|
|
|
455
708
|
this.settings = copied;
|
|
456
709
|
}
|
|
457
710
|
|
|
458
|
-
|
|
711
|
+
private analysisEvent(prompt: CollectedPrompt, result: AnalysisResult): FluencyEvent {
|
|
459
712
|
const copiedPrompt: CollectedPrompt = {
|
|
460
713
|
promptHash: prompt.promptHash,
|
|
461
714
|
prose: prompt.prose,
|
|
462
715
|
observedAt: prompt.observedAt,
|
|
463
716
|
};
|
|
717
|
+
if (result.schemaVersion !== 3) throw new Error("Invalid schema-v4 history event");
|
|
718
|
+
return decodeHistoryLine({
|
|
719
|
+
schemaVersion: HISTORY_SCHEMA_VERSION,
|
|
720
|
+
type: "analysis",
|
|
721
|
+
at: copiedPrompt.observedAt,
|
|
722
|
+
prompt: copiedPrompt,
|
|
723
|
+
wordCount: countEnglishWords(copiedPrompt.prose),
|
|
724
|
+
result: copyAnalysisResult(result),
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
appendAnalysis(prompt: CollectedPrompt, result: AnalysisResult): Promise<void> {
|
|
464
729
|
let event: FluencyEvent;
|
|
465
730
|
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
|
-
});
|
|
731
|
+
event = this.analysisEvent(prompt, result);
|
|
476
732
|
} catch (error) {
|
|
477
733
|
return Promise.reject(error);
|
|
478
734
|
}
|
|
@@ -482,6 +738,34 @@ export class FluencyStore {
|
|
|
482
738
|
});
|
|
483
739
|
}
|
|
484
740
|
|
|
741
|
+
/** Append precomputed result only while clear generation and analyzer authorization remain exact. */
|
|
742
|
+
conditionalAppendAnalysis(
|
|
743
|
+
fence: AnalysisCommitFence,
|
|
744
|
+
prompt: CollectedPrompt,
|
|
745
|
+
result: AnalysisResult,
|
|
746
|
+
): Promise<ConditionalAppendResult> {
|
|
747
|
+
let event: FluencyEvent;
|
|
748
|
+
try {
|
|
749
|
+
event = this.analysisEvent(prompt, result);
|
|
750
|
+
} catch (error) {
|
|
751
|
+
return Promise.reject(error);
|
|
752
|
+
}
|
|
753
|
+
return this.enqueueMutation(async (signal) => {
|
|
754
|
+
if (this.historyGeneration !== fence.historyGeneration) return "generation-stale";
|
|
755
|
+
const current = this.settings;
|
|
756
|
+
if (!current.enabled
|
|
757
|
+
|| current.consentedAt === undefined
|
|
758
|
+
|| current.enabled !== fence.enabled
|
|
759
|
+
|| current.consentedAt !== fence.consentedAt) return "authorization-stale";
|
|
760
|
+
if (current.provider !== fence.provider
|
|
761
|
+
|| current.modelId !== fence.modelId
|
|
762
|
+
|| current.minimumConfidence !== fence.minimumConfidence) return "analyzer-stale";
|
|
763
|
+
this.assertHistoryReady();
|
|
764
|
+
await this.appendUnsafe(event, signal);
|
|
765
|
+
return "committed";
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
|
|
485
769
|
private reviewPatternBatch(
|
|
486
770
|
patternId: string,
|
|
487
771
|
decision: "accepted" | "dismissed",
|