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,102 @@
1
+ import { createHash } from "node:crypto";
2
+ import { sanitizeCollectedInput } from "./sanitize.js";
3
+ import type { CollectedPrompt } from "./types.js";
4
+
5
+ const SECRET_PATTERNS = [
6
+ /-----BEGIN(?: [A-Z0-9]+)* PRIVATE KEY-----[\s\S]*?-----END(?: [A-Z0-9]+)* PRIVATE KEY-----/gi,
7
+ /\bAuthorization\s*:\s*(?:Bearer|Basic)\s+[^\s,;]+/gi,
8
+ /\bBearer\s+[^\s,;]+/gi,
9
+ /\bgithub_pat_[A-Za-z0-9_]{8,}\b/g,
10
+ /\bgh[opusr]_[A-Za-z0-9_]{8,}\b/g,
11
+ /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g,
12
+ /(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?![A-Za-z0-9_-])/g,
13
+ /\bsk-[A-Za-z0-9_-]{20,}\b/g,
14
+ /\b[a-z][a-z0-9+.-]*:\/\/[^\s/@:]+:[^@\s/]+@/gi,
15
+ /\b(?:api\s*[_-]?\s*key|token|secret|password|client[_-]?secret|database[_-]?url|[A-Z][A-Z0-9_]*(?:API[_-]?KEY|ACCESS[_-]?KEY|SECRET[_-]?KEY|TOKEN|SECRET|PASSWORD))\s*(?::|=|\s)\s*[^\s,;]+/gi,
16
+ ];
17
+
18
+ function stripBlockCode(text: string): string {
19
+ const proseLines: string[] = [];
20
+ let fence: { marker: "`" | "~"; length: number } | undefined;
21
+
22
+ for (const line of text.split("\n")) {
23
+ const withoutCarriageReturn = line.replace(/\r$/, "");
24
+
25
+ if (fence) {
26
+ const closing = /^ {0,3}(`+|~+)[ \t]*$/.exec(withoutCarriageReturn);
27
+ if (closing?.[1]?.startsWith(fence.marker) && closing[1].length >= fence.length) {
28
+ fence = undefined;
29
+ }
30
+ continue;
31
+ }
32
+
33
+ const opening = /^ {0,3}(`{3,}|~{3,})/.exec(withoutCarriageReturn);
34
+ if (opening?.[1]) {
35
+ fence = {
36
+ marker: opening[1][0] as "`" | "~",
37
+ length: opening[1].length,
38
+ };
39
+ continue;
40
+ }
41
+
42
+ if (/^(?: {4,}|\t)/.test(withoutCarriageReturn)) continue;
43
+ proseLines.push(line);
44
+ }
45
+
46
+ return proseLines.join("\n");
47
+ }
48
+
49
+ function stripInlineCode(text: string): string {
50
+ let prose = "";
51
+ let index = 0;
52
+
53
+ while (index < text.length) {
54
+ if (text[index] !== "`") {
55
+ prose += text[index];
56
+ index += 1;
57
+ continue;
58
+ }
59
+
60
+ let openerEnd = index;
61
+ while (text[openerEnd] === "`") openerEnd += 1;
62
+ const delimiterLength = openerEnd - index;
63
+ let searchIndex = openerEnd;
64
+ let closingEnd: number | undefined;
65
+
66
+ while (searchIndex < text.length) {
67
+ if (text[searchIndex] !== "`") {
68
+ searchIndex += 1;
69
+ continue;
70
+ }
71
+
72
+ let candidateEnd = searchIndex;
73
+ while (text[candidateEnd] === "`") candidateEnd += 1;
74
+ if (candidateEnd - searchIndex === delimiterLength) {
75
+ closingEnd = candidateEnd;
76
+ break;
77
+ }
78
+ searchIndex = candidateEnd;
79
+ }
80
+
81
+ prose += " ";
82
+ if (closingEnd === undefined) break;
83
+ index = closingEnd;
84
+ }
85
+
86
+ return prose;
87
+ }
88
+
89
+ export function collectPrompt(text: string, observedAt = Date.now()): CollectedPrompt | undefined {
90
+ if (text.trimStart().startsWith("/")) return undefined;
91
+
92
+ let prose = stripInlineCode(stripBlockCode(sanitizeCollectedInput(text)));
93
+ for (const pattern of SECRET_PATTERNS) prose = prose.replace(pattern, "[REDACTED]");
94
+ prose = prose.replace(/\s+/g, " ").trim();
95
+ if (prose.length < 8 || (prose.match(/\p{L}/gu)?.length ?? 0) < 3) return undefined;
96
+
97
+ return {
98
+ prose,
99
+ observedAt,
100
+ promptHash: createHash("sha256").update(prose).digest("hex"),
101
+ };
102
+ }
@@ -0,0 +1,65 @@
1
+ import type { AnalyzerMistake, RawAnalyzerMistake } from "./types.js";
2
+
3
+ const MAX_EXCERPT_LENGTH = 500;
4
+
5
+ export function materializeMistake(prose: string, raw: RawAnalyzerMistake): AnalyzerMistake {
6
+ if (raw.original.length === 0) throw new Error("Source quote not found exactly once");
7
+
8
+ const expansion = raw.correction.length - raw.original.length;
9
+ const sourceLimit = Math.min(MAX_EXCERPT_LENGTH, MAX_EXCERPT_LENGTH - expansion);
10
+ if (
11
+ raw.original.length > MAX_EXCERPT_LENGTH
12
+ || raw.correction.length > MAX_EXCERPT_LENGTH
13
+ || sourceLimit < raw.original.length
14
+ ) {
15
+ throw new Error("Invalid analysis result");
16
+ }
17
+
18
+ const first = prose.indexOf(raw.original);
19
+ const second = first < 0 ? -1 : prose.indexOf(raw.original, first + 1);
20
+ if (first < 0 || second >= 0) throw new Error("Source quote not found exactly once");
21
+
22
+ const segments = [...new Intl.Segmenter("en", { granularity: "sentence" }).segment(prose)];
23
+ const sentenceIndex = segments.findIndex(
24
+ (segment) => first >= segment.index && first < segment.index + segment.segment.length,
25
+ );
26
+ if (sentenceIndex < 0) throw new Error("Could not derive sentence context");
27
+
28
+ const startIndex = raw.contextScope === "previous-and-current"
29
+ ? Math.max(0, sentenceIndex - 1)
30
+ : sentenceIndex;
31
+ const endIndex = raw.contextScope === "current-and-next"
32
+ ? Math.min(segments.length - 1, sentenceIndex + 1)
33
+ : sentenceIndex;
34
+ const excerptStart = segments[startIndex]!.index;
35
+ const endSegment = segments[endIndex]!;
36
+ const excerptEnd = endSegment.index + endSegment.segment.length;
37
+ let sourceExcerpt = prose.slice(excerptStart, excerptEnd).trim();
38
+
39
+ if (sourceExcerpt.length > sourceLimit) {
40
+ const localIndex = sourceExcerpt.indexOf(raw.original);
41
+ if (localIndex < 0) throw new Error("Invalid analysis result");
42
+
43
+ const earliestWindowStart = Math.max(0, localIndex + raw.original.length - sourceLimit);
44
+ const latestWindowStart = Math.min(localIndex, sourceExcerpt.length - sourceLimit);
45
+ const preferredWindowStart = localIndex - 200;
46
+ const windowStart = Math.min(
47
+ latestWindowStart,
48
+ Math.max(earliestWindowStart, preferredWindowStart),
49
+ );
50
+ sourceExcerpt = sourceExcerpt.slice(windowStart, windowStart + sourceLimit);
51
+ }
52
+
53
+ const localIndex = sourceExcerpt.indexOf(raw.original);
54
+ if (localIndex < 0) throw new Error("Invalid analysis result");
55
+ const correctedExcerpt = sourceExcerpt.slice(0, localIndex)
56
+ + raw.correction
57
+ + sourceExcerpt.slice(localIndex + raw.original.length);
58
+ if (
59
+ sourceExcerpt.length > MAX_EXCERPT_LENGTH
60
+ || correctedExcerpt.length > MAX_EXCERPT_LENGTH
61
+ ) {
62
+ throw new Error("Invalid analysis result");
63
+ }
64
+ return { ...raw, sourceExcerpt, correctedExcerpt };
65
+ }
@@ -0,0 +1,147 @@
1
+ import { visibleWidth } from "@earendil-works/pi-tui";
2
+
3
+ export interface CompactDiffInput {
4
+ sourceExcerpt: string;
5
+ correctedExcerpt: string;
6
+ original: string;
7
+ correction: string;
8
+ }
9
+
10
+ export interface CompactDiffStyles {
11
+ deletion(text: string): string;
12
+ insertion(text: string): string;
13
+ }
14
+
15
+ interface Token {
16
+ text: string;
17
+ start: number;
18
+ end: number;
19
+ }
20
+
21
+ const TOKEN = /\s+|[\p{L}\p{N}'’-]+|[^\s]/gu;
22
+ export interface CompactDiffFallback {
23
+ source: string;
24
+ correction: string;
25
+ }
26
+
27
+ const fallbackOutputs = new WeakMap<string[], CompactDiffFallback>();
28
+
29
+ function tokenize(text: string): Token[] {
30
+ return [...text.matchAll(TOKEN)].map((match) => ({
31
+ text: match[0],
32
+ start: match.index,
33
+ end: match.index + match[0].length,
34
+ }));
35
+ }
36
+
37
+ function verifiedOffset(input: CompactDiffInput): number | undefined {
38
+ if (input.original.length === 0) return undefined;
39
+ let offset = input.sourceExcerpt.indexOf(input.original);
40
+ while (offset >= 0) {
41
+ const candidate = input.sourceExcerpt.slice(0, offset)
42
+ + input.correction
43
+ + input.sourceExcerpt.slice(offset + input.original.length);
44
+ if (candidate === input.correctedExcerpt) return offset;
45
+ offset = input.sourceExcerpt.indexOf(input.original, offset + 1);
46
+ }
47
+ return undefined;
48
+ }
49
+
50
+ function visibleWhitespace(text: string): string {
51
+ return [...text].map((character) => {
52
+ if (character === "\t") return "⇥";
53
+ if (character === "\n" || character === "\r") return "↵";
54
+ return "␠";
55
+ }).join("");
56
+ }
57
+
58
+ function differingTokenSpan(source: string, corrected: string): CompactDiffFallback {
59
+ const sourceTokens = tokenize(source);
60
+ const correctedTokens = tokenize(corrected);
61
+ let prefix = 0;
62
+ while (
63
+ prefix < sourceTokens.length
64
+ && prefix < correctedTokens.length
65
+ && sourceTokens[prefix]!.text === correctedTokens[prefix]!.text
66
+ ) prefix += 1;
67
+
68
+ let suffix = 0;
69
+ while (
70
+ suffix < sourceTokens.length - prefix
71
+ && suffix < correctedTokens.length - prefix
72
+ && sourceTokens[sourceTokens.length - 1 - suffix]!.text === correctedTokens[correctedTokens.length - 1 - suffix]!.text
73
+ ) suffix += 1;
74
+
75
+ const span = (text: string, tokens: Token[]): string => {
76
+ const first = tokens[prefix];
77
+ const last = tokens[tokens.length - suffix - 1];
78
+ if (!first || !last || last.end < first.start) return "∅";
79
+ const raw = text.slice(first.start, last.end);
80
+ if (raw.length === 0) return "∅";
81
+ if (/^\s+$/u.test(raw)) return visibleWhitespace(raw);
82
+ return raw.trim();
83
+ };
84
+ return { source: span(source, sourceTokens), correction: span(corrected, correctedTokens) };
85
+ }
86
+
87
+ function fallback(input: CompactDiffInput): string[] {
88
+ const fallback = differingTokenSpan(input.sourceExcerpt, input.correctedExcerpt);
89
+ const lines = [fallback.source, `└─ ${fallback.correction}`];
90
+ fallbackOutputs.set(lines, fallback);
91
+ return lines;
92
+ }
93
+
94
+ /** Return layout metadata only for the unverifiable/legacy token-diff fallback. */
95
+ export function compactDiffFallback(lines: string[]): CompactDiffFallback | undefined {
96
+ return fallbackOutputs.get(lines);
97
+ }
98
+
99
+ /** Render one deterministic, compact edit without mutating either excerpt. */
100
+ export function renderCompactDiff(input: CompactDiffInput, styles: CompactDiffStyles): string[] {
101
+ const excerptOffset = verifiedOffset(input);
102
+ if (excerptOffset === undefined) return fallback(input);
103
+
104
+ const removed = tokenize(input.original);
105
+ const added = tokenize(input.correction);
106
+ let prefix = 0;
107
+ while (prefix < removed.length && prefix < added.length && removed[prefix]!.text === added[prefix]!.text) {
108
+ prefix += 1;
109
+ }
110
+ let suffix = 0;
111
+ while (
112
+ suffix < removed.length - prefix
113
+ && suffix < added.length - prefix
114
+ && removed[removed.length - 1 - suffix]!.text === added[added.length - 1 - suffix]!.text
115
+ ) suffix += 1;
116
+
117
+ const removedStart = prefix < removed.length ? removed[prefix]!.start : input.original.length;
118
+ const removedEndIndex = removed.length - suffix - 1;
119
+ const removedEnd = removedEndIndex >= prefix ? removed[removedEndIndex]!.end : removedStart;
120
+ const addedStart = prefix < added.length ? added[prefix]!.start : input.correction.length;
121
+ const addedEndIndex = added.length - suffix - 1;
122
+ const addedEnd = addedEndIndex >= prefix ? added[addedEndIndex]!.end : addedStart;
123
+ const removedText = input.original.slice(removedStart, removedEnd);
124
+ const addedText = input.correction.slice(addedStart, addedEnd);
125
+
126
+ if (removedText && addedText) {
127
+ const annotationOffset = excerptOffset + removedStart;
128
+ return [
129
+ input.sourceExcerpt,
130
+ `${" ".repeat(visibleWidth(input.sourceExcerpt.slice(0, annotationOffset)))}└─ ${addedText}`,
131
+ ];
132
+ }
133
+
134
+ if (removedText) {
135
+ const start = excerptOffset + removedStart;
136
+ const end = excerptOffset + removedEnd;
137
+ return [input.sourceExcerpt.slice(0, start) + styles.deletion(removedText) + input.sourceExcerpt.slice(end)];
138
+ }
139
+
140
+ if (addedText) {
141
+ const start = excerptOffset + addedStart;
142
+ const end = excerptOffset + addedEnd;
143
+ return [input.correctedExcerpt.slice(0, start) + styles.insertion(addedText) + input.correctedExcerpt.slice(end)];
144
+ }
145
+
146
+ return input.sourceExcerpt === input.correctedExcerpt ? [input.sourceExcerpt] : fallback(input);
147
+ }
@@ -0,0 +1,51 @@
1
+ const HISTORY_GENERATION = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2
+
3
+ export interface HistoryGenerationMarker {
4
+ generation: string;
5
+ resetPending: boolean;
6
+ legacy: boolean;
7
+ }
8
+
9
+ function invalid(): never {
10
+ throw new Error("Invalid history generation");
11
+ }
12
+
13
+ function validGeneration(value: unknown): value is string {
14
+ return typeof value === "string" && HISTORY_GENERATION.test(value);
15
+ }
16
+
17
+ export function decodeHistoryGenerationMarker(serialized: string): HistoryGenerationMarker {
18
+ const trimmed = serialized.trim();
19
+ if (validGeneration(trimmed)) {
20
+ return { generation: trimmed, resetPending: false, legacy: true };
21
+ }
22
+
23
+ let value: unknown;
24
+ try {
25
+ value = JSON.parse(trimmed) as unknown;
26
+ } catch {
27
+ return invalid();
28
+ }
29
+ if (!value || typeof value !== "object" || Array.isArray(value)) return invalid();
30
+ const marker = value as Record<string, unknown>;
31
+ const keys = Object.keys(marker).sort();
32
+ if (
33
+ keys.length !== 2
34
+ || keys[0] !== "generation"
35
+ || keys[1] !== "resetPending"
36
+ || !validGeneration(marker.generation)
37
+ || typeof marker.resetPending !== "boolean"
38
+ ) return invalid();
39
+ return { generation: marker.generation, resetPending: marker.resetPending, legacy: false };
40
+ }
41
+
42
+ export function encodeHistoryGenerationMarker(marker: {
43
+ generation: string;
44
+ resetPending: boolean;
45
+ }): string {
46
+ if (!validGeneration(marker.generation)) return invalid();
47
+ return `${JSON.stringify({
48
+ generation: marker.generation,
49
+ resetPending: marker.resetPending,
50
+ })}\n`;
51
+ }
@@ -0,0 +1,266 @@
1
+ import { isErrantErrorType } from "./taxonomy.js";
2
+ import {
3
+ HISTORY_SCHEMA_VERSION,
4
+ type AnalysisResult,
5
+ type AnalyzerMistake,
6
+ type ContextScope,
7
+ type DemonstratedFix,
8
+ type EnglishObservation,
9
+ type FluencyEvent,
10
+ type MistakeOccurrence,
11
+ type SnapshotPattern,
12
+ } from "./types.js";
13
+
14
+ const EVENT_TYPES = new Set(["analysis", "review", "snapshot"]);
15
+ const CONTEXT_SCOPES = new Set<ContextScope>(["sentence", "previous-and-current", "current-and-next"]);
16
+ const PATTERN_KEY = /^[a-z]+(?:[.-][a-z0-9]+)+$/;
17
+
18
+ export class HistorySchemaMismatchError extends Error {
19
+ constructor() {
20
+ super("Unsupported fluency history schema");
21
+ this.name = "HistorySchemaMismatchError";
22
+ }
23
+ }
24
+
25
+ function invalid(): never {
26
+ throw new Error("Invalid schema-v4 history event");
27
+ }
28
+
29
+ function record(value: unknown): Record<string, unknown> {
30
+ if (!value || typeof value !== "object" || Array.isArray(value)) return invalid();
31
+ return value as Record<string, unknown>;
32
+ }
33
+
34
+ function text(value: unknown, allowEmpty = false): string {
35
+ if (typeof value !== "string" || (!allowEmpty && value.length === 0)) return invalid();
36
+ return value;
37
+ }
38
+
39
+ function finite(value: unknown): number {
40
+ if (typeof value !== "number" || !Number.isFinite(value)) return invalid();
41
+ return value;
42
+ }
43
+
44
+ function timestamp(value: unknown): number {
45
+ const decoded = finite(value);
46
+ if (!Number.isFinite(new Date(decoded).getTime())) return invalid();
47
+ return decoded;
48
+ }
49
+
50
+ function nonnegativeInteger(value: unknown): number {
51
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) return invalid();
52
+ return value;
53
+ }
54
+
55
+ function confidence(value: unknown): number {
56
+ const decoded = finite(value);
57
+ if (decoded < 0 || decoded > 1) return invalid();
58
+ return decoded;
59
+ }
60
+
61
+ function stringArray(value: unknown): string[] {
62
+ if (!Array.isArray(value)) return invalid();
63
+ const decoded = value.map((item) => text(item));
64
+ if (new Set(decoded).size !== decoded.length) return invalid();
65
+ return decoded;
66
+ }
67
+
68
+ export function isLocalDate(value: unknown): value is string {
69
+ if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
70
+ const timestamp = Date.parse(`${value}T00:00:00.000Z`);
71
+ return Number.isFinite(timestamp) && new Date(timestamp).toISOString().slice(0, 10) === value;
72
+ }
73
+
74
+ function localDate(value: unknown): string {
75
+ if (!isLocalDate(value)) return invalid();
76
+ return value;
77
+ }
78
+
79
+ function decodeMistake(value: unknown): AnalyzerMistake {
80
+ const candidate = record(value);
81
+ const contextScope = text(candidate.contextScope);
82
+ if (!CONTEXT_SCOPES.has(contextScope as ContextScope)) return invalid();
83
+ const errorType = text(candidate.errorType);
84
+ if (!isErrantErrorType(errorType)) return invalid();
85
+ const patternKey = text(candidate.patternKey);
86
+ if (!PATTERN_KEY.test(patternKey)) return invalid();
87
+ return {
88
+ original: text(candidate.original),
89
+ correction: text(candidate.correction, true),
90
+ contextScope: contextScope as ContextScope,
91
+ sourceExcerpt: text(candidate.sourceExcerpt),
92
+ correctedExcerpt: text(candidate.correctedExcerpt, true),
93
+ explanation: text(candidate.explanation),
94
+ errorType,
95
+ patternKey,
96
+ confidence: confidence(candidate.confidence),
97
+ };
98
+ }
99
+
100
+ function decodeFix(value: unknown): DemonstratedFix {
101
+ const candidate = record(value);
102
+ const patternKey = text(candidate.patternKey);
103
+ if (!PATTERN_KEY.test(patternKey)) return invalid();
104
+ return {
105
+ patternKey,
106
+ evidence: candidate.evidence === undefined ? "" : text(candidate.evidence),
107
+ confidence: confidence(candidate.confidence),
108
+ };
109
+ }
110
+
111
+ function decodeAnalysisResult(value: unknown): AnalysisResult {
112
+ const candidate = record(value);
113
+ if (candidate.schemaVersion !== 3 || (candidate.language !== "en" && candidate.language !== "other")) return invalid();
114
+ if (!Array.isArray(candidate.mistakes) || !Array.isArray(candidate.demonstratedFixes)) return invalid();
115
+ const mistakes = candidate.mistakes.map(decodeMistake);
116
+ const demonstratedFixes = candidate.demonstratedFixes.map(decodeFix);
117
+ if (candidate.language === "other" && (mistakes.length > 0 || demonstratedFixes.length > 0)) return invalid();
118
+ return { schemaVersion: 3, language: candidate.language, mistakes, demonstratedFixes };
119
+ }
120
+
121
+ function decodePattern(value: unknown): SnapshotPattern {
122
+ const candidate = record(value);
123
+ const errorType = text(candidate.errorType);
124
+ if (!isErrantErrorType(errorType)) return invalid();
125
+ const patternKey = text(candidate.patternKey);
126
+ if (!PATTERN_KEY.test(patternKey)) return invalid();
127
+ return {
128
+ id: text(candidate.id),
129
+ patternKey,
130
+ original: text(candidate.original),
131
+ correction: text(candidate.correction, true),
132
+ sourceExcerpt: text(candidate.sourceExcerpt),
133
+ correctedExcerpt: text(candidate.correctedExcerpt, true),
134
+ explanation: text(candidate.explanation),
135
+ errorType,
136
+ confidence: confidence(candidate.confidence),
137
+ firstSeenAt: timestamp(candidate.firstSeenAt),
138
+ lastSeenAt: timestamp(candidate.lastSeenAt),
139
+ occurrenceCount: nonnegativeInteger(candidate.occurrenceCount),
140
+ demonstratedFixCount: nonnegativeInteger(candidate.demonstratedFixCount),
141
+ };
142
+ }
143
+
144
+ function decodeObservation(value: unknown): EnglishObservation {
145
+ const candidate = record(value);
146
+ return {
147
+ promptHash: text(candidate.promptHash),
148
+ observedAt: timestamp(candidate.observedAt),
149
+ localDate: localDate(candidate.localDate),
150
+ wordCount: nonnegativeInteger(candidate.wordCount),
151
+ occurrenceIds: stringArray(candidate.occurrenceIds),
152
+ };
153
+ }
154
+
155
+ function decodeOccurrence(value: unknown): MistakeOccurrence {
156
+ const candidate = record(value);
157
+ if (candidate.decision !== "pending" && candidate.decision !== "accepted" && candidate.decision !== "dismissed") return invalid();
158
+ return {
159
+ id: text(candidate.id),
160
+ promptHash: text(candidate.promptHash),
161
+ patternId: text(candidate.patternId),
162
+ patternKey: text(candidate.patternKey),
163
+ observedAt: timestamp(candidate.observedAt),
164
+ localDate: localDate(candidate.localDate),
165
+ decision: candidate.decision,
166
+ };
167
+ }
168
+
169
+ function decodeAnalysis(candidate: Record<string, unknown>): FluencyEvent {
170
+ const prompt = record(candidate.prompt);
171
+ return {
172
+ schemaVersion: HISTORY_SCHEMA_VERSION,
173
+ type: "analysis",
174
+ at: timestamp(candidate.at),
175
+ prompt: {
176
+ promptHash: text(prompt.promptHash),
177
+ observedAt: timestamp(prompt.observedAt),
178
+ prose: prompt.prose === undefined ? "" : text(prompt.prose, true),
179
+ },
180
+ wordCount: nonnegativeInteger(candidate.wordCount),
181
+ result: decodeAnalysisResult(candidate.result),
182
+ };
183
+ }
184
+
185
+ function decodeReview(candidate: Record<string, unknown>): FluencyEvent {
186
+ if (candidate.decision !== "accepted" && candidate.decision !== "dismissed") return invalid();
187
+ const occurrenceIds = stringArray(candidate.occurrenceIds);
188
+ if (occurrenceIds.length === 0) return invalid();
189
+ return {
190
+ schemaVersion: HISTORY_SCHEMA_VERSION,
191
+ type: "review",
192
+ at: timestamp(candidate.at),
193
+ occurrenceIds,
194
+ decision: candidate.decision,
195
+ };
196
+ }
197
+
198
+ function decodeSnapshot(candidate: Record<string, unknown>): FluencyEvent {
199
+ if (
200
+ !Array.isArray(candidate.patterns)
201
+ || !Array.isArray(candidate.observations)
202
+ || !Array.isArray(candidate.occurrences)
203
+ ) return invalid();
204
+ const patterns = candidate.patterns.map(decodePattern);
205
+ const observations = candidate.observations.map(decodeObservation);
206
+ const occurrences = candidate.occurrences.map(decodeOccurrence);
207
+ const processedPromptHashes = stringArray(candidate.processedPromptHashes);
208
+
209
+ const patternById = new Map(patterns.map((pattern) => [pattern.id, pattern]));
210
+ const observationByHash = new Map(observations.map((observation) => [observation.promptHash, observation]));
211
+ const occurrenceById = new Map(occurrences.map((occurrence) => [occurrence.id, occurrence]));
212
+ if (
213
+ patternById.size !== patterns.length
214
+ || observationByHash.size !== observations.length
215
+ || occurrenceById.size !== occurrences.length
216
+ ) return invalid();
217
+
218
+ for (const observation of observations) {
219
+ for (const id of observation.occurrenceIds) {
220
+ const occurrence = occurrenceById.get(id);
221
+ if (!occurrence || occurrence.promptHash !== observation.promptHash) return invalid();
222
+ }
223
+ }
224
+ for (const occurrence of occurrences) {
225
+ const suffix = occurrence.id.slice(occurrence.promptHash.length + 1);
226
+ const observation = observationByHash.get(occurrence.promptHash);
227
+ const pattern = patternById.get(occurrence.patternId);
228
+ if (
229
+ !occurrence.id.startsWith(`${occurrence.promptHash}:`)
230
+ || !/^\d+$/.test(suffix)
231
+ || !observation?.occurrenceIds.includes(occurrence.id)
232
+ || occurrence.observedAt !== observation.observedAt
233
+ || occurrence.localDate !== observation.localDate
234
+ || pattern?.patternKey !== occurrence.patternKey
235
+ ) return invalid();
236
+ }
237
+ if (
238
+ observations.some((observation) => !processedPromptHashes.includes(observation.promptHash))
239
+ ) return invalid();
240
+
241
+ return {
242
+ schemaVersion: HISTORY_SCHEMA_VERSION,
243
+ type: "snapshot",
244
+ at: timestamp(candidate.at),
245
+ patterns,
246
+ observations,
247
+ occurrences,
248
+ processedPromptHashes,
249
+ };
250
+ }
251
+
252
+ export function decodeHistoryLine(value: unknown): FluencyEvent {
253
+ const candidate = record(value);
254
+ if (candidate.schemaVersion !== HISTORY_SCHEMA_VERSION) throw new HistorySchemaMismatchError();
255
+ if (typeof candidate.type !== "string" || !EVENT_TYPES.has(candidate.type)) return invalid();
256
+ if (candidate.type === "analysis") return decodeAnalysis(candidate);
257
+ if (candidate.type === "review") return decodeReview(candidate);
258
+ return decodeSnapshot(candidate);
259
+ }
260
+
261
+ export function encodeHistoryEvent(event: FluencyEvent): string {
262
+ return JSON.stringify(event, (key, value) =>
263
+ key === "prose" || key === "evidence"
264
+ ? undefined
265
+ : value);
266
+ }