pi-fluency 0.1.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,40 @@
1
+ const TERMINAL_ESCAPE_SEQUENCE = /(?:\u001b\[[0-?]*[ -/]*[@-~]|\u009b[0-?]*[ -/]*[@-~]|\u001b\][^\u0007]*(?:\u0007|\u001b\\))/g;
2
+ const TERMINAL_CONTROLS = /[\u0000-\u001f\u007f-\u009f]/g;
3
+ const TERMINAL_CONTROLS_EXCEPT_TEXT_WHITESPACE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g;
4
+
5
+ export function stripTerminalSequences(value: string, preserveTextWhitespace = false): string {
6
+ return value
7
+ .replace(TERMINAL_ESCAPE_SEQUENCE, "")
8
+ .replace(preserveTextWhitespace ? TERMINAL_CONTROLS_EXCEPT_TEXT_WHITESPACE : TERMINAL_CONTROLS, "");
9
+ }
10
+
11
+ /** Preserve line structure until code blocks and inline code have been removed. */
12
+ export function sanitizeCollectedInput(value: string): string {
13
+ return stripTerminalSequences(value, true);
14
+ }
15
+
16
+ /** Validate one model-returned field without changing meaningful whitespace. */
17
+ export function sanitizeAnalyzerField(
18
+ value: unknown,
19
+ allowEmpty = false,
20
+ maximumLength = 500,
21
+ ): string | undefined {
22
+ if (typeof value !== "string") return undefined;
23
+ const sanitized = stripTerminalSequences(value);
24
+ return (allowEmpty || sanitized.length > 0) && sanitized.length <= maximumLength ? sanitized : undefined;
25
+ }
26
+
27
+ /** Sanitize one finding field before it crosses the durable state boundary. */
28
+ export function sanitizePersistedFinding(
29
+ value: unknown,
30
+ maximumLength: number,
31
+ allowEmpty = true,
32
+ ): string | undefined {
33
+ return sanitizeAnalyzerField(value, allowEmpty, maximumLength);
34
+ }
35
+
36
+ /** Remove terminal payloads, trim, and bound labels or user-visible error detail. */
37
+ export function sanitizeTerminalLabel(value: unknown, maximumLength = 500): string {
38
+ if (typeof value !== "string") return "";
39
+ return stripTerminalSequences(value).trim().slice(0, maximumLength);
40
+ }
@@ -0,0 +1,29 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { sanitizeTerminalLabel } from "./sanitize.js";
3
+ import type { FluencyStore } from "./store.js";
4
+
5
+ export async function runSetup(
6
+ ctx: ExtensionCommandContext,
7
+ store: FluencyStore,
8
+ options: { enable?: boolean; now?: () => number } = {},
9
+ ): Promise<boolean> {
10
+ const models = ctx.modelRegistry.getAvailable();
11
+ const labels = models.map((model) =>
12
+ `${sanitizeTerminalLabel(model.provider, 100) || "unknown-provider"}/${sanitizeTerminalLabel(model.id, 100) || "unknown-model"}`);
13
+ const selected = await ctx.ui.select("Pi Fluency analyzer model", labels);
14
+ if (!selected) return false;
15
+ const model = models[labels.indexOf(selected)];
16
+ if (!model) return false;
17
+ const approved = await ctx.ui.confirm(
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.`,
20
+ );
21
+ if (!approved) return false;
22
+ await store.updateSettings({
23
+ ...(options.enable === false ? {} : { enabled: true }),
24
+ consentedAt: (options.now ?? Date.now)(),
25
+ provider: model.provider,
26
+ modelId: model.id,
27
+ });
28
+ return true;
29
+ }
@@ -0,0 +1,192 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ ANALYSIS_SCHEMA_VERSION,
4
+ type AnalysisResult,
5
+ type AnalyzerMistake,
6
+ type EnglishObservation,
7
+ type FluencyEvent,
8
+ type FluencyState,
9
+ type MistakeOccurrence,
10
+ type MistakePattern,
11
+ type SnapshotPattern,
12
+ } from "./types.js";
13
+ import { sanitizePersistedFinding } from "./sanitize.js";
14
+
15
+ function safeText(value: string): string {
16
+ const sanitized = sanitizePersistedFinding(value, Number.POSITIVE_INFINITY);
17
+ if (sanitized === undefined) throw new Error("Invalid persisted finding");
18
+ return sanitized;
19
+ }
20
+
21
+ function patternId(patternKey: string): string {
22
+ return createHash("sha256").update(patternKey.trim().toLowerCase()).digest("hex").slice(0, 16);
23
+ }
24
+
25
+ export function localDateKey(timestamp: number): string {
26
+ const date = new Date(timestamp);
27
+ if (!Number.isFinite(date.getTime())) throw new Error("Invalid observation timestamp");
28
+ const year = date.getFullYear();
29
+ const month = String(date.getMonth() + 1).padStart(2, "0");
30
+ const day = String(date.getDate()).padStart(2, "0");
31
+ return `${year}-${month}-${day}`;
32
+ }
33
+
34
+ function normalizeMistake(mistake: AnalyzerMistake): AnalyzerMistake {
35
+ return {
36
+ ...mistake,
37
+ original: safeText(mistake.original),
38
+ correction: safeText(mistake.correction),
39
+ sourceExcerpt: safeText(mistake.sourceExcerpt),
40
+ correctedExcerpt: safeText(mistake.correctedExcerpt),
41
+ explanation: safeText(mistake.explanation),
42
+ patternKey: safeText(mistake.patternKey),
43
+ };
44
+ }
45
+
46
+ export function copyPattern(pattern: MistakePattern | SnapshotPattern): MistakePattern {
47
+ return {
48
+ ...pattern,
49
+ original: safeText(pattern.original),
50
+ correction: safeText(pattern.correction),
51
+ sourceExcerpt: safeText(pattern.sourceExcerpt),
52
+ correctedExcerpt: safeText(pattern.correctedExcerpt),
53
+ explanation: safeText(pattern.explanation),
54
+ patternKey: safeText(pattern.patternKey),
55
+ };
56
+ }
57
+
58
+ export function copyAnalysisResult(result: AnalysisResult): AnalysisResult {
59
+ return {
60
+ schemaVersion: ANALYSIS_SCHEMA_VERSION,
61
+ language: result.language,
62
+ mistakes: result.mistakes.map(normalizeMistake),
63
+ demonstratedFixes: result.demonstratedFixes.map((fix) => ({
64
+ ...fix,
65
+ patternKey: safeText(fix.patternKey),
66
+ evidence: safeText(fix.evidence),
67
+ })),
68
+ };
69
+ }
70
+
71
+ export function copyObservation(observation: EnglishObservation): EnglishObservation {
72
+ return { ...observation, occurrenceIds: [...observation.occurrenceIds] };
73
+ }
74
+
75
+ export function copyOccurrence(occurrence: MistakeOccurrence): MistakeOccurrence {
76
+ return { ...occurrence };
77
+ }
78
+
79
+ export function createFluencyState(): FluencyState {
80
+ return {
81
+ patterns: new Map(),
82
+ observations: new Map(),
83
+ occurrences: new Map(),
84
+ processedPromptHashes: new Set(),
85
+ };
86
+ }
87
+
88
+ export function replaceFluencyState(target: FluencyState, source: FluencyState): void {
89
+ target.patterns.clear();
90
+ target.observations.clear();
91
+ target.occurrences.clear();
92
+ target.processedPromptHashes.clear();
93
+ for (const [id, pattern] of source.patterns) target.patterns.set(id, copyPattern(pattern));
94
+ for (const [hash, observation] of source.observations) target.observations.set(hash, copyObservation(observation));
95
+ for (const [id, occurrence] of source.occurrences) target.occurrences.set(id, copyOccurrence(occurrence));
96
+ for (const hash of source.processedPromptHashes) target.processedPromptHashes.add(hash);
97
+ }
98
+
99
+ export function reduceHistoryEvent(state: FluencyState, event: FluencyEvent): void {
100
+ if (event.type === "snapshot") {
101
+ state.patterns.clear();
102
+ state.observations.clear();
103
+ state.occurrences.clear();
104
+ state.processedPromptHashes.clear();
105
+ for (const pattern of event.patterns) state.patterns.set(pattern.id, copyPattern(pattern));
106
+ for (const observation of event.observations) {
107
+ state.observations.set(observation.promptHash, copyObservation(observation));
108
+ }
109
+ for (const occurrence of event.occurrences) state.occurrences.set(occurrence.id, copyOccurrence(occurrence));
110
+ for (const hash of event.processedPromptHashes) state.processedPromptHashes.add(hash);
111
+ return;
112
+ }
113
+ if (event.type === "review") {
114
+ if (event.occurrenceIds.some((id) => !state.occurrences.has(id))) {
115
+ throw new Error("Invalid review event reference");
116
+ }
117
+ for (const id of event.occurrenceIds) {
118
+ const occurrence = state.occurrences.get(id);
119
+ if (occurrence?.decision === "pending") state.occurrences.set(id, { ...occurrence, decision: event.decision });
120
+ }
121
+ return;
122
+ }
123
+
124
+ const promptHash = event.prompt.promptHash;
125
+ if (state.processedPromptHashes.has(promptHash)) return;
126
+ const observedAt = event.prompt.observedAt;
127
+ const result = copyAnalysisResult(event.result);
128
+ if (result.language === "other") {
129
+ state.processedPromptHashes.add(promptHash);
130
+ return;
131
+ }
132
+
133
+ const localDate = localDateKey(observedAt);
134
+ state.processedPromptHashes.add(promptHash);
135
+ const occurrenceIds: string[] = [];
136
+ for (const [index, mistake] of result.mistakes.entries()) {
137
+ const id = patternId(mistake.patternKey);
138
+ const current = state.patterns.get(id);
139
+ state.patterns.set(id, current ? {
140
+ ...current,
141
+ original: mistake.original,
142
+ correction: mistake.correction,
143
+ explanation: mistake.explanation,
144
+ sourceExcerpt: mistake.sourceExcerpt,
145
+ correctedExcerpt: mistake.correctedExcerpt,
146
+ errorType: mistake.errorType,
147
+ confidence: Math.max(current.confidence, mistake.confidence),
148
+ lastSeenAt: observedAt,
149
+ occurrenceCount: current.occurrenceCount + 1,
150
+ } : {
151
+ id,
152
+ patternKey: mistake.patternKey,
153
+ original: mistake.original,
154
+ correction: mistake.correction,
155
+ sourceExcerpt: mistake.sourceExcerpt,
156
+ correctedExcerpt: mistake.correctedExcerpt,
157
+ explanation: mistake.explanation,
158
+ errorType: mistake.errorType,
159
+ confidence: mistake.confidence,
160
+ firstSeenAt: observedAt,
161
+ lastSeenAt: observedAt,
162
+ occurrenceCount: 1,
163
+ demonstratedFixCount: 0,
164
+ });
165
+
166
+ const occurrenceId = `${promptHash}:${index}`;
167
+ occurrenceIds.push(occurrenceId);
168
+ state.occurrences.set(occurrenceId, {
169
+ id: occurrenceId,
170
+ promptHash,
171
+ patternId: id,
172
+ patternKey: mistake.patternKey,
173
+ observedAt,
174
+ localDate,
175
+ decision: "pending",
176
+ });
177
+ }
178
+
179
+ state.observations.set(promptHash, {
180
+ promptHash,
181
+ observedAt,
182
+ localDate,
183
+ wordCount: event.wordCount,
184
+ occurrenceIds,
185
+ });
186
+
187
+ for (const fix of result.demonstratedFixes) {
188
+ const id = patternId(fix.patternKey);
189
+ const current = state.patterns.get(id);
190
+ if (current) state.patterns.set(id, { ...current, demonstratedFixCount: current.demonstratedFixCount + 1 });
191
+ }
192
+ }
@@ -0,0 +1,39 @@
1
+ export type StatusErrorReason = "auth" | "model" | "analyze" | "store" | "migrate";
2
+
3
+ export type StatusState =
4
+ | {
5
+ kind: "progress";
6
+ pendingOccurrences: number;
7
+ activeRules: number;
8
+ sparkline: string;
9
+ ratePerThousand: number | undefined;
10
+ }
11
+ | { kind: "initial-loading" }
12
+ | { kind: "error"; reason: StatusErrorReason }
13
+ | { kind: "hidden" };
14
+
15
+ const VALID_SPARKLINE = /^[·▁▂▃▄▅▆▇█]{7}$/u;
16
+ const EMPTY_SPARKLINE = "·······";
17
+
18
+ const whole = (value: unknown): number =>
19
+ typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
20
+
21
+ const rate = (value: number | undefined): string =>
22
+ value === undefined || !Number.isFinite(value) || value < 0 ? "—/k" : `${value.toFixed(1)}/k`;
23
+
24
+ export function formatStatus(state: StatusState): string | undefined {
25
+ switch (state.kind) {
26
+ case "progress": {
27
+ const pending = whole(state.pendingOccurrences);
28
+ const active = whole(state.activeRules);
29
+ const sparkline = VALID_SPARKLINE.test(state.sparkline) ? state.sparkline : EMPTY_SPARKLINE;
30
+ return `${pending > 0 ? "󰇮" : "󰇰"} ${pending} 󰌵 ${active} ${sparkline} ${rate(state.ratePerThousand)}`;
31
+ }
32
+ case "initial-loading":
33
+ return `󰇰 … 󰌵 … ${EMPTY_SPARKLINE} —/k`;
34
+ case "error":
35
+ return `󰅙 ERR ${state.reason}`;
36
+ case "hidden":
37
+ return undefined;
38
+ }
39
+ }