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,158 @@
1
+ import { createHash } from "node:crypto";
2
+ import { selectedTargetForMistake } from "./analytics.js";
3
+ import { isGloballySnoozed } from "./practice-settings.js";
4
+ import {
5
+ ANALYSIS_SCHEMA_VERSION,
6
+ type AnalysisResult,
7
+ type AnalyzerMistake,
8
+ type FluencySettings,
9
+ type PracticePolicySnapshot,
10
+ type PracticeSettings,
11
+ type PracticeTarget,
12
+ } from "./types.js";
13
+
14
+ export const MAX_COACHING_MISTAKES = 3;
15
+
16
+ export interface CoachingEligibilityInput {
17
+ source: string;
18
+ idle: boolean;
19
+ textOnly: boolean;
20
+ collectionEligible: boolean;
21
+ sessionSnoozed: boolean;
22
+ now?: number;
23
+ policy: PracticePolicySnapshot;
24
+ }
25
+
26
+ export type CoachingDecision = "edit" | "send-once" | "snooze-session" | "snooze-five-hours";
27
+ export type CoachingTerminalOutcome = "edit" | "continue";
28
+ export type AnalysisReuseAction = "commit-foreground" | "queue-background" | "discard";
29
+
30
+ export type CoachingRevalidation =
31
+ | "unchanged"
32
+ | "analytics-disabled"
33
+ | "analyzer-changed"
34
+ | "gate-changed";
35
+
36
+ function stableDigest(value: unknown): string {
37
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex");
38
+ }
39
+
40
+ function sorted(values: readonly string[]): string[] {
41
+ return [...new Set(values)].sort((left, right) => left.localeCompare(right));
42
+ }
43
+
44
+ function canonicalTargets(targets: readonly PracticeTarget[]): Array<[string, string[]]> {
45
+ return targets
46
+ .map((target): [string, string[]] => [target.explanation, sorted(target.memberPatternKeys)])
47
+ .sort(([left], [right]) => left.localeCompare(right));
48
+ }
49
+
50
+ /** Identity of output-affecting analyzer configuration. Built from request-scoped settings. */
51
+ export function analyzerResultFingerprint(settings: FluencySettings): string {
52
+ return stableDigest({
53
+ schemaVersion: ANALYSIS_SCHEMA_VERSION,
54
+ provider: settings.provider ?? null,
55
+ modelId: settings.modelId ?? null,
56
+ minimumConfidence: settings.minimumConfidence,
57
+ });
58
+ }
59
+
60
+ /** Identity of every policy value that can change whether coaching gates submission. */
61
+ export function gatePolicyFingerprint(
62
+ snapshot: PracticePolicySnapshot,
63
+ sessionSnoozed = false,
64
+ ): string {
65
+ return stableDigest({
66
+ analyzer: analyzerResultFingerprint(snapshot.settings),
67
+ analyticsEnabled: snapshot.settings.enabled,
68
+ analyticsConsent: snapshot.settings.consentedAt ?? null,
69
+ ignoredPatternKeys: sorted(snapshot.settings.ignoredPatternKeys),
70
+ ignoredCategories: sorted(snapshot.settings.ignoredCategories),
71
+ practiceRevision: snapshot.practice.revision,
72
+ practiceEpoch: snapshot.practice.epoch,
73
+ practiceEnabled: snapshot.practice.enabled,
74
+ practiceConsent: snapshot.practice.consentedAt ?? null,
75
+ snoozedUntil: snapshot.practice.snoozedUntil ?? null,
76
+ targets: canonicalTargets(snapshot.practice.targets),
77
+ sessionSnoozed,
78
+ });
79
+ }
80
+
81
+ export function isAnalyticsPersistenceEnabled(settings: FluencySettings): boolean {
82
+ return settings.enabled
83
+ && typeof settings.consentedAt === "number"
84
+ && Number.isFinite(settings.consentedAt)
85
+ && typeof settings.provider === "string"
86
+ && settings.provider.length > 0
87
+ && typeof settings.modelId === "string"
88
+ && settings.modelId.length > 0;
89
+ }
90
+
91
+ export function isCoachingEligible(input: CoachingEligibilityInput): boolean {
92
+ const now = input.now ?? Date.now();
93
+ const { settings, practice } = input.policy;
94
+ return input.source === "interactive"
95
+ && input.idle
96
+ && input.textOnly
97
+ && input.collectionEligible
98
+ && !input.sessionSnoozed
99
+ && isAnalyticsPersistenceEnabled(settings)
100
+ && practice.enabled
101
+ && typeof practice.consentedAt === "number"
102
+ && Number.isFinite(practice.consentedAt)
103
+ && practice.targets.length > 0
104
+ && !isGloballySnoozed(practice, now);
105
+ }
106
+
107
+ /** Selected, non-ignored matches only. Complete result remains untouched for persistence. */
108
+ export function selectedCoachingMistakes(
109
+ result: AnalysisResult,
110
+ settings: FluencySettings,
111
+ practice: PracticeSettings,
112
+ ): AnalyzerMistake[] {
113
+ const ignoredKeys = new Set(settings.ignoredPatternKeys);
114
+ const ignoredCategories = new Set(settings.ignoredCategories);
115
+ return result.mistakes.filter((mistake) => selectedTargetForMistake(
116
+ mistake,
117
+ practice.targets,
118
+ ignoredKeys,
119
+ ignoredCategories,
120
+ ) !== undefined);
121
+ }
122
+
123
+ /** Stable bounded presentation; never mutates or truncates full analysis result. */
124
+ export function boundedCoachingMistakes(
125
+ result: AnalysisResult,
126
+ settings: FluencySettings,
127
+ practice: PracticeSettings,
128
+ maximum = MAX_COACHING_MISTAKES,
129
+ ): AnalyzerMistake[] {
130
+ if (!Number.isSafeInteger(maximum) || maximum < 0) throw new Error("Invalid coaching mistake limit");
131
+ return selectedCoachingMistakes(result, settings, practice).slice(0, maximum);
132
+ }
133
+
134
+ /** Change-specific persistence/gating fallback after fresh policy reread. */
135
+ export function revalidateCoachingPolicy(
136
+ before: PracticePolicySnapshot,
137
+ after: PracticePolicySnapshot,
138
+ beforeSessionSnoozed = false,
139
+ afterSessionSnoozed = false,
140
+ ): CoachingRevalidation {
141
+ if (!isAnalyticsPersistenceEnabled(after.settings)) return "analytics-disabled";
142
+ if (analyzerResultFingerprint(before.settings) !== analyzerResultFingerprint(after.settings)) {
143
+ return "analyzer-changed";
144
+ }
145
+ if (gatePolicyFingerprint(before, beforeSessionSnoozed)
146
+ !== gatePolicyFingerprint(after, afterSessionSnoozed)) return "gate-changed";
147
+ return "unchanged";
148
+ }
149
+
150
+ /** Persistence handoff for terminal arbiter. Actual generation fence remains store-owned. */
151
+ export function analysisReuseAction(
152
+ terminal: CoachingTerminalOutcome,
153
+ revalidation: CoachingRevalidation,
154
+ ): AnalysisReuseAction {
155
+ if (terminal === "edit" || revalidation === "analytics-disabled") return "discard";
156
+ if (revalidation === "analyzer-changed") return "queue-background";
157
+ return "commit-foreground";
158
+ }
@@ -87,9 +87,10 @@ function stripInlineCode(text: string): string {
87
87
  }
88
88
 
89
89
  export function collectPrompt(text: string, observedAt = Date.now()): CollectedPrompt | undefined {
90
- if (text.trimStart().startsWith("/")) return undefined;
90
+ const sanitized = sanitizeCollectedInput(text);
91
+ if (sanitized.trimStart().startsWith("/")) return undefined;
91
92
 
92
- let prose = stripInlineCode(stripBlockCode(sanitizeCollectedInput(text)));
93
+ let prose = stripInlineCode(stripBlockCode(sanitized));
93
94
  for (const pattern of SECRET_PATTERNS) prose = prose.replace(pattern, "[REDACTED]");
94
95
  prose = prose.replace(/\s+/g, " ").trim();
95
96
  if (prose.length < 8 || (prose.match(/\p{L}/gu)?.length ?? 0) < 3) return undefined;