pi-fluency 0.1.2 → 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.
@@ -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
- appendAnalysis(prompt: CollectedPrompt, result: AnalysisResult): Promise<void> {
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
- 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
- });
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 =