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.
@@ -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 { appendFile, chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
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 = lock;
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
- appendAnalysis(prompt: CollectedPrompt, result: AnalysisResult): Promise<void> {
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
- 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
- });
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",