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.
- package/README.md +30 -5
- package/extensions/pi-fluency/analytics.ts +158 -4
- package/extensions/pi-fluency/analyzer.ts +29 -3
- package/extensions/pi-fluency/coaching-overlay.ts +259 -0
- package/extensions/pi-fluency/coaching.ts +158 -0
- package/extensions/pi-fluency/collector.ts +3 -2
- package/extensions/pi-fluency/index.ts +538 -27
- package/extensions/pi-fluency/overlay.ts +296 -40
- package/extensions/pi-fluency/practice-settings.ts +201 -0
- package/extensions/pi-fluency/setup.ts +1 -1
- package/extensions/pi-fluency/status.ts +3 -0
- package/extensions/pi-fluency/store.ts +294 -13
- package/extensions/pi-fluency/types.ts +50 -0
- package/extensions/pi-fluency/worker.ts +408 -27
- package/package.json +1 -1
|
@@ -8,18 +8,47 @@ import type {
|
|
|
8
8
|
} from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { Key } from "@earendil-works/pi-tui";
|
|
10
10
|
import { AnalyzerConfigurationError, ModelAnalyzer, type Analyzer } from "./analyzer.js";
|
|
11
|
-
import { computeFluencyAnalytics } from "./analytics.js";
|
|
11
|
+
import { computeFluencyAnalytics, resolvePracticeTargets, selectPracticeAnalysisContext } from "./analytics.js";
|
|
12
12
|
import { collectPrompt } from "./collector.js";
|
|
13
|
+
import {
|
|
14
|
+
showCoachingOverlay,
|
|
15
|
+
type CoachingCheckResult,
|
|
16
|
+
type CoachingOverlayDecision,
|
|
17
|
+
type CoachingSnoozeDecision,
|
|
18
|
+
type CoachingSnoozeHandler,
|
|
19
|
+
} from "./coaching-overlay.js";
|
|
20
|
+
import {
|
|
21
|
+
analysisReuseAction,
|
|
22
|
+
analyzerResultFingerprint,
|
|
23
|
+
isCoachingEligible,
|
|
24
|
+
revalidateCoachingPolicy,
|
|
25
|
+
selectedCoachingMistakes,
|
|
26
|
+
} from "./coaching.js";
|
|
13
27
|
import { showFluencyOverlay, type FluencyView } from "./overlay.js";
|
|
28
|
+
import { PracticeSessionSnooze } from "./practice-settings.js";
|
|
14
29
|
import { sanitizeTerminalLabel } from "./sanitize.js";
|
|
15
30
|
import { runSetup } from "./setup.js";
|
|
16
31
|
import { formatStatus, type StatusErrorReason, type StatusState } from "./status.js";
|
|
17
|
-
import { FluencyStore } from "./store.js";
|
|
18
|
-
import type { FluencySettings } from "./types.js";
|
|
19
|
-
import { FluencyWorker } from "./worker.js";
|
|
32
|
+
import { FluencyStore, type AnalysisCommitFence } from "./store.js";
|
|
33
|
+
import type { FluencySettings, PracticeSettings } from "./types.js";
|
|
34
|
+
import { FluencyWorker, type ForegroundAnalysisOutcome } from "./worker.js";
|
|
20
35
|
|
|
21
36
|
const STATUS_KEY = "pi-fluency";
|
|
22
|
-
const USAGE = "Usage: /fluency [pause|resume|status|model|clear|stats]";
|
|
37
|
+
const USAGE = "Usage: /fluency [pause|resume|status|model|clear|stats|practice [on|off|resume|reset]]";
|
|
38
|
+
const PRACTICE_DISCLOSURE = "Before main submission, full sanitized draft goes to configured Fluency model and may be analyzed even if you later choose not to send it.";
|
|
39
|
+
const PRACTICE_CHECK_TIMEOUT_MS = 30_000;
|
|
40
|
+
const PRACTICE_CHECK_ABORT_GRACE_MS = 100;
|
|
41
|
+
|
|
42
|
+
function practiceFailureMessage(kind: Exclude<ForegroundAnalysisOutcome["kind"], "success">): string {
|
|
43
|
+
switch (kind) {
|
|
44
|
+
case "busy": return "Sent without practice check — analyzer was busy for 30 seconds.";
|
|
45
|
+
case "timeout": return "Sent without practice check — analyzer timed out after 30 seconds.";
|
|
46
|
+
case "error": return "Sent without practice check — analyzer failed.";
|
|
47
|
+
case "shutdown": return "Sent without practice check — analyzer stopped during reload or shutdown.";
|
|
48
|
+
case "quarantined": return "Sent without practice check — analyzer unavailable; restart Pi to restore practice.";
|
|
49
|
+
case "cancelled": return "Sent without practice check — practice settings changed.";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
23
52
|
|
|
24
53
|
export interface OpenInboxOptions {
|
|
25
54
|
signal: AbortSignal;
|
|
@@ -35,11 +64,20 @@ export type OpenInbox = (
|
|
|
35
64
|
options: OpenInboxOptions,
|
|
36
65
|
) => Promise<void> | void;
|
|
37
66
|
|
|
67
|
+
export type ShowCoaching = (
|
|
68
|
+
ctx: ExtensionContext,
|
|
69
|
+
check: Promise<CoachingCheckResult>,
|
|
70
|
+
signal?: AbortSignal,
|
|
71
|
+
saveSnooze?: CoachingSnoozeHandler,
|
|
72
|
+
) => Promise<CoachingOverlayDecision>;
|
|
73
|
+
|
|
38
74
|
export interface ExtensionDependencies {
|
|
39
75
|
rootDir?: string;
|
|
40
|
-
analyzerFactory?: (ctx: ExtensionContext, store: FluencyStore) => Analyzer;
|
|
76
|
+
analyzerFactory?: (ctx: ExtensionContext, store: FluencyStore, settings?: FluencySettings) => Analyzer;
|
|
41
77
|
now?: () => number;
|
|
78
|
+
openStore?: (rootDir: string) => Promise<FluencyStore>;
|
|
42
79
|
openInbox?: OpenInbox;
|
|
80
|
+
showCoaching?: ShowCoaching;
|
|
43
81
|
}
|
|
44
82
|
|
|
45
83
|
interface ResolvedDependencies extends ExtensionDependencies {
|
|
@@ -63,6 +101,27 @@ function hasValidConfiguration(settings: FluencySettings, ctx: ExtensionContext)
|
|
|
63
101
|
return ctx.modelRegistry.find(settings.provider!, settings.modelId!) !== undefined;
|
|
64
102
|
}
|
|
65
103
|
|
|
104
|
+
function fenceFromPolicy(policy: { settings: FluencySettings; historyGeneration: string }): AnalysisCommitFence {
|
|
105
|
+
const settings = policy.settings;
|
|
106
|
+
return {
|
|
107
|
+
historyGeneration: policy.historyGeneration,
|
|
108
|
+
enabled: settings.enabled,
|
|
109
|
+
minimumConfidence: settings.minimumConfidence,
|
|
110
|
+
...(settings.consentedAt === undefined ? {} : { consentedAt: settings.consentedAt }),
|
|
111
|
+
...(settings.provider === undefined ? {} : { provider: settings.provider }),
|
|
112
|
+
...(settings.modelId === undefined ? {} : { modelId: settings.modelId }),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function sameAnalysisFence(left: AnalysisCommitFence, right: AnalysisCommitFence): boolean {
|
|
117
|
+
return left.historyGeneration === right.historyGeneration
|
|
118
|
+
&& left.enabled === right.enabled
|
|
119
|
+
&& left.consentedAt === right.consentedAt
|
|
120
|
+
&& left.provider === right.provider
|
|
121
|
+
&& left.modelId === right.modelId
|
|
122
|
+
&& left.minimumConfidence === right.minimumConfidence;
|
|
123
|
+
}
|
|
124
|
+
|
|
66
125
|
function registerHandlers(pi: ExtensionAPI, dependencies: ResolvedDependencies): void {
|
|
67
126
|
let storeRef: FluencyStore | undefined;
|
|
68
127
|
let storePromise: Promise<FluencyStore> | undefined;
|
|
@@ -75,6 +134,26 @@ function registerHandlers(pi: ExtensionAPI, dependencies: ResolvedDependencies):
|
|
|
75
134
|
const notifiedErrors = new Set<string>();
|
|
76
135
|
const inputSessionId = randomUUID();
|
|
77
136
|
let inputSequence = 0;
|
|
137
|
+
const practiceSessionSnooze = new PracticeSessionSnooze();
|
|
138
|
+
const coachingControllers = new Set<AbortController>();
|
|
139
|
+
const scheduledCommits = new Set<Promise<void>>();
|
|
140
|
+
|
|
141
|
+
const sessionFile = (ctx: ExtensionContext): string | undefined =>
|
|
142
|
+
ctx.sessionManager?.getSessionFile?.();
|
|
143
|
+
const sessionEntries = (ctx: ExtensionContext) => ctx.sessionManager?.getEntries?.() ?? [];
|
|
144
|
+
const isSessionPracticeSnoozed = (
|
|
145
|
+
ctx: ExtensionContext,
|
|
146
|
+
store: FluencyStore,
|
|
147
|
+
practice: PracticeSettings = store.getPracticeSettings(),
|
|
148
|
+
): boolean => practiceSessionSnooze.restore(sessionEntries(ctx), sessionFile(ctx), practice.epoch);
|
|
149
|
+
const resumeSessionPractice = (ctx: ExtensionContext, store: FluencyStore): void => {
|
|
150
|
+
const practice = store.getPracticeSettings();
|
|
151
|
+
practiceSessionSnooze.resume(
|
|
152
|
+
sessionFile(ctx),
|
|
153
|
+
practice.epoch,
|
|
154
|
+
(customType, data) => pi.appendEntry(customType, data),
|
|
155
|
+
);
|
|
156
|
+
};
|
|
78
157
|
|
|
79
158
|
const publishStatus = (ctx: ExtensionContext, state: StatusState): void => {
|
|
80
159
|
const text = formatStatus(state);
|
|
@@ -139,13 +218,36 @@ function registerHandlers(pi: ExtensionAPI, dependencies: ResolvedDependencies):
|
|
|
139
218
|
};
|
|
140
219
|
|
|
141
220
|
const getStore = async (): Promise<FluencyStore> => {
|
|
142
|
-
storePromise ??=
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
221
|
+
storePromise ??= (dependencies.openStore?.(dependencies.rootDir) ?? FluencyStore.open(dependencies.rootDir))
|
|
222
|
+
.then((store) => {
|
|
223
|
+
if (!shuttingDown) storeRef = store;
|
|
224
|
+
return store;
|
|
225
|
+
});
|
|
146
226
|
return storePromise;
|
|
147
227
|
};
|
|
148
228
|
|
|
229
|
+
const getStoreBefore = async (deadline: number): Promise<FluencyStore> => {
|
|
230
|
+
const acquisition = getStore().then(
|
|
231
|
+
(store) => ({ kind: "store" as const, store }),
|
|
232
|
+
() => ({ kind: "error" as const }),
|
|
233
|
+
);
|
|
234
|
+
const remaining = deadline - Date.now();
|
|
235
|
+
if (remaining <= 0) throw new Error("Store acquisition deadline exceeded");
|
|
236
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
237
|
+
try {
|
|
238
|
+
const result = await Promise.race([
|
|
239
|
+
acquisition,
|
|
240
|
+
new Promise<{ kind: "deadline" }>((resolve) => {
|
|
241
|
+
timer = setTimeout(() => resolve({ kind: "deadline" }), remaining);
|
|
242
|
+
}),
|
|
243
|
+
]);
|
|
244
|
+
if (result.kind === "store") return result.store;
|
|
245
|
+
throw new Error(result.kind === "error" ? "Store acquisition failed" : "Store acquisition deadline exceeded");
|
|
246
|
+
} finally {
|
|
247
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
149
251
|
const analyzerErrorReason = (error: unknown): StatusErrorReason => {
|
|
150
252
|
if (!(error instanceof AnalyzerConfigurationError)) return "analyze";
|
|
151
253
|
return /auth|api[- ]?key|credential|token/i.test(error.message) ? "auth" : "model";
|
|
@@ -161,9 +263,12 @@ function registerHandlers(pi: ExtensionAPI, dependencies: ResolvedDependencies):
|
|
|
161
263
|
}
|
|
162
264
|
};
|
|
163
265
|
|
|
164
|
-
const createAnalyzer = (
|
|
165
|
-
|
|
166
|
-
|
|
266
|
+
const createAnalyzer = (
|
|
267
|
+
ctx: ExtensionContext,
|
|
268
|
+
store: FluencyStore,
|
|
269
|
+
settings: FluencySettings = store.getSettings(),
|
|
270
|
+
): Analyzer => {
|
|
271
|
+
if (dependencies.analyzerFactory) return dependencies.analyzerFactory(ctx, store, settings);
|
|
167
272
|
const model = settings.provider && settings.modelId
|
|
168
273
|
? ctx.modelRegistry.find(settings.provider, settings.modelId)
|
|
169
274
|
: undefined;
|
|
@@ -179,11 +284,24 @@ function registerHandlers(pi: ExtensionAPI, dependencies: ResolvedDependencies):
|
|
|
179
284
|
ctxRef = ctx;
|
|
180
285
|
workerRef ??= new FluencyWorker({
|
|
181
286
|
analyzer: createAnalyzer(ctx, store),
|
|
287
|
+
getAnalyzerConfiguration: async (job) => {
|
|
288
|
+
if (job.fence === undefined) return undefined;
|
|
289
|
+
const currentCtx = ctxRef ?? ctx;
|
|
290
|
+
const fresh = await store.getFreshPolicySnapshot(Date.now() + 1_000);
|
|
291
|
+
const freshFence = fenceFromPolicy(fresh);
|
|
292
|
+
if (!sameAnalysisFence(job.fence, freshFence) || !hasConfiguredIdentity(fresh.settings)) return undefined;
|
|
293
|
+
return {
|
|
294
|
+
fingerprint: analyzerResultFingerprint(fresh.settings),
|
|
295
|
+
analyzer: createAnalyzer(currentCtx, store, fresh.settings),
|
|
296
|
+
};
|
|
297
|
+
},
|
|
182
298
|
isIdle: () => ctxRef?.isIdle() ?? false,
|
|
183
299
|
getPatterns: () => store.listKnownPatterns(),
|
|
184
|
-
onResult: async (prompt, result) => {
|
|
300
|
+
onResult: async (prompt, result, fence) => {
|
|
301
|
+
if (fence === undefined) return;
|
|
185
302
|
try {
|
|
186
|
-
await store.
|
|
303
|
+
const committed = await store.conditionalAppendAnalysis(fence, prompt, result);
|
|
304
|
+
if (committed !== "committed") return;
|
|
187
305
|
} catch (error) {
|
|
188
306
|
if (ctxRef) setError(ctxRef, "store", error);
|
|
189
307
|
return;
|
|
@@ -242,6 +360,10 @@ function registerHandlers(pi: ExtensionAPI, dependencies: ResolvedDependencies):
|
|
|
242
360
|
onMutationError,
|
|
243
361
|
initialView,
|
|
244
362
|
dependencies.now,
|
|
363
|
+
{
|
|
364
|
+
sessionSnoozed: () => isSessionPracticeSnoozed(ctx, store),
|
|
365
|
+
resumeSession: () => resumeSessionPractice(ctx, store),
|
|
366
|
+
},
|
|
245
367
|
),
|
|
246
368
|
);
|
|
247
369
|
overlayOpen = current;
|
|
@@ -288,6 +410,44 @@ function registerHandlers(pi: ExtensionAPI, dependencies: ResolvedDependencies):
|
|
|
288
410
|
await openInbox(ctx, store, "stats");
|
|
289
411
|
return;
|
|
290
412
|
}
|
|
413
|
+
if (action === "practice") {
|
|
414
|
+
await openInbox(ctx, store, "stats");
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (action === "practice on") {
|
|
418
|
+
const practice = store.getPracticeSettings();
|
|
419
|
+
if (practice.consentedAt === undefined) {
|
|
420
|
+
const confirmed = await ctx.ui.confirm("Enable Pi Fluency practice?", PRACTICE_DISCLOSURE);
|
|
421
|
+
if (!confirmed) {
|
|
422
|
+
ctx.ui.notify("Pi Fluency practice unchanged", "info");
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
await store.activatePractice(dependencies.now());
|
|
426
|
+
} else {
|
|
427
|
+
await store.setPracticeEnabled(true);
|
|
428
|
+
}
|
|
429
|
+
ctx.ui.notify("Pi Fluency practice enabled", "info");
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (action === "practice off") {
|
|
433
|
+
await store.setPracticeEnabled(false);
|
|
434
|
+
ctx.ui.notify("Pi Fluency practice disabled", "info");
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (action === "practice resume") {
|
|
438
|
+
// Mutation rereads sidecar under global lock, so stale process cache cannot miss durable snooze.
|
|
439
|
+
await store.resumePractice();
|
|
440
|
+
if (isSessionPracticeSnoozed(ctx, store)) resumeSessionPractice(ctx, store);
|
|
441
|
+
ctx.ui.notify("Pi Fluency practice resumed now", "info");
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
if (action === "practice reset") {
|
|
445
|
+
if (await ctx.ui.confirm("Reset Pi Fluency practice?", "This clears practice targets, consent, mode, and snoozes. Fluency history stays unchanged.")) {
|
|
446
|
+
await store.resetPractice();
|
|
447
|
+
ctx.ui.notify("Pi Fluency practice reset", "info");
|
|
448
|
+
}
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
291
451
|
if (action === "pause") {
|
|
292
452
|
await store.updateSettings({ enabled: false });
|
|
293
453
|
await workerRef?.shutdown();
|
|
@@ -320,9 +480,15 @@ function registerHandlers(pi: ExtensionAPI, dependencies: ResolvedDependencies):
|
|
|
320
480
|
const state = active
|
|
321
481
|
? "enabled"
|
|
322
482
|
: settings.enabled ? "inactive (configuration invalid)" : "paused";
|
|
483
|
+
const practice = store.getPracticeSettings();
|
|
484
|
+
const globalSnoozed = (practice.snoozedUntil ?? 0) > dependencies.now();
|
|
485
|
+
const sessionSnoozed = isSessionPracticeSnoozed(ctx, store);
|
|
486
|
+
const practiceSnooze = globalSnoozed && sessionSnoozed
|
|
487
|
+
? "session+5-hour"
|
|
488
|
+
: sessionSnoozed ? "session" : globalSnoozed ? "5-hour" : "none";
|
|
323
489
|
if (!active) clearStatus(ctx);
|
|
324
490
|
ctx.ui.notify(
|
|
325
|
-
`Pi Fluency: ${state}; model=${model}; queued=${snapshot.queued}; dropped=${snapshot.dropped}; warnings=${store.getWarnings().length}`,
|
|
491
|
+
`Pi Fluency: ${state}; model=${model}; queued=${snapshot.queued}; dropped=${snapshot.dropped}; warnings=${store.getWarnings().length}; practice=${practice.enabled ? "on" : "off"}; practice-selected=${practice.targets.length}; practice-snooze=${practiceSnooze}`,
|
|
326
492
|
"info",
|
|
327
493
|
);
|
|
328
494
|
return;
|
|
@@ -369,14 +535,11 @@ function registerHandlers(pi: ExtensionAPI, dependencies: ResolvedDependencies):
|
|
|
369
535
|
});
|
|
370
536
|
|
|
371
537
|
pi.on("input", async (event, ctx) => {
|
|
538
|
+
const handlerStartedAt = Date.now();
|
|
539
|
+
const foregroundDeadline = handlerStartedAt + PRACTICE_CHECK_TIMEOUT_MS;
|
|
540
|
+
const foregroundCoordinatorDeadline = foregroundDeadline - PRACTICE_CHECK_ABORT_GRACE_MS;
|
|
372
541
|
if (shuttingDown || event.source !== "interactive") return;
|
|
373
542
|
ctxRef = ctx;
|
|
374
|
-
const store = await getStore();
|
|
375
|
-
if (shuttingDown) return;
|
|
376
|
-
if (!hasValidConfiguration(store.getSettings(), ctx)) {
|
|
377
|
-
publishConfigurationFailureOrClear(ctx, store);
|
|
378
|
-
return;
|
|
379
|
-
}
|
|
380
543
|
const collected = collectPrompt(event.text, dependencies.now());
|
|
381
544
|
if (!collected) return;
|
|
382
545
|
const prompt = {
|
|
@@ -385,12 +548,353 @@ function registerHandlers(pi: ExtensionAPI, dependencies: ResolvedDependencies):
|
|
|
385
548
|
.update(`${collected.promptHash}\0${inputSessionId}\0${inputSequence++}`)
|
|
386
549
|
.digest("hex"),
|
|
387
550
|
};
|
|
388
|
-
|
|
551
|
+
const idleTextOnly = ctx.isIdle()
|
|
552
|
+
&& event.streamingBehavior === undefined
|
|
553
|
+
&& (event.images?.length ?? 0) === 0;
|
|
554
|
+
|
|
555
|
+
// Enter may already have emptied Pi's editor. Preserve received bytes before any preflight I/O.
|
|
556
|
+
if (idleTextOnly) {
|
|
557
|
+
try {
|
|
558
|
+
ctx.ui.setEditorText(event.text);
|
|
559
|
+
} catch {
|
|
560
|
+
// Original event still owns submission. Only ordinary background collection may follow.
|
|
561
|
+
try {
|
|
562
|
+
const fallbackStore = await getStoreBefore(foregroundDeadline);
|
|
563
|
+
const fresh = await fallbackStore.getFreshPolicySnapshot(foregroundDeadline);
|
|
564
|
+
if (!shuttingDown && hasValidConfiguration(fresh.settings, ctx)) {
|
|
565
|
+
getWorker(ctx, fallbackStore).enqueue(prompt, fenceFromPolicy(fresh));
|
|
566
|
+
}
|
|
567
|
+
} catch { /* Original input remains fail-open. */ }
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
let store: FluencyStore;
|
|
389
573
|
try {
|
|
390
|
-
|
|
391
|
-
} catch
|
|
392
|
-
|
|
574
|
+
store = await getStoreBefore(foregroundDeadline);
|
|
575
|
+
} catch {
|
|
576
|
+
if (!idleTextOnly) return;
|
|
577
|
+
try {
|
|
578
|
+
ctx.ui.setEditorText("");
|
|
579
|
+
ctx.ui.notify("Sent without practice check — policy unavailable.", "warning");
|
|
580
|
+
return { action: "continue" };
|
|
581
|
+
} catch {
|
|
582
|
+
ctx.ui.notify("Not sent — editor could not be cleared.", "error");
|
|
583
|
+
return { action: "handled" };
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (shuttingDown) {
|
|
587
|
+
if (idleTextOnly) {
|
|
588
|
+
try { ctx.ui.setEditorText(""); } catch { return { action: "handled" }; }
|
|
589
|
+
return { action: "continue" };
|
|
590
|
+
}
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const queueBackground = (policy: { settings: FluencySettings; historyGeneration: string }): void => {
|
|
594
|
+
try {
|
|
595
|
+
getWorker(ctx, store).enqueue(prompt, fenceFromPolicy(policy));
|
|
596
|
+
} catch (error) {
|
|
597
|
+
setError(ctx, analyzerErrorReason(error), error);
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
let initialPolicy;
|
|
601
|
+
try {
|
|
602
|
+
initialPolicy = await store.getFreshPolicySnapshot(foregroundDeadline);
|
|
603
|
+
} catch {
|
|
604
|
+
if (!idleTextOnly) return;
|
|
605
|
+
try {
|
|
606
|
+
ctx.ui.setEditorText("");
|
|
607
|
+
ctx.ui.notify("Sent without practice check — policy unavailable.", "warning");
|
|
608
|
+
return { action: "continue" };
|
|
609
|
+
} catch {
|
|
610
|
+
ctx.ui.notify("Not sent — editor could not be cleared.", "error");
|
|
611
|
+
return { action: "handled" };
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
if (!hasValidConfiguration(initialPolicy.settings, ctx)) {
|
|
615
|
+
publishConfigurationFailureOrClear(ctx, store);
|
|
616
|
+
if (!idleTextOnly) return;
|
|
617
|
+
try { ctx.ui.setEditorText(""); } catch {
|
|
618
|
+
ctx.ui.notify("Not sent — editor could not be cleared.", "error");
|
|
619
|
+
return { action: "handled" };
|
|
620
|
+
}
|
|
621
|
+
return { action: "continue" };
|
|
622
|
+
}
|
|
623
|
+
if (store.hasProcessedPromptHash(prompt.promptHash)) {
|
|
624
|
+
if (!idleTextOnly) return;
|
|
625
|
+
try { ctx.ui.setEditorText(""); } catch { return { action: "handled" }; }
|
|
626
|
+
return { action: "continue" };
|
|
627
|
+
}
|
|
628
|
+
if (!idleTextOnly) {
|
|
629
|
+
queueBackground(initialPolicy);
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
let backgroundAllowed = true;
|
|
633
|
+
let backgroundPolicy = initialPolicy;
|
|
634
|
+
const restoreInputStatus = (): void => {
|
|
635
|
+
if (shuttingDown || !backgroundAllowed) clearStatus(ctx);
|
|
636
|
+
else publishProgress(ctx, store);
|
|
637
|
+
};
|
|
638
|
+
const clearForContinue = (): boolean => {
|
|
639
|
+
try {
|
|
640
|
+
ctx.ui.setEditorText("");
|
|
641
|
+
return true;
|
|
642
|
+
} catch {
|
|
643
|
+
ctx.ui.notify("Not sent — editor could not be cleared.", "error");
|
|
644
|
+
restoreInputStatus();
|
|
645
|
+
return false;
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
const failOpen = (message: string): { action: "continue" } | { action: "handled" } => {
|
|
649
|
+
if (backgroundAllowed) queueBackground(backgroundPolicy);
|
|
650
|
+
if (!clearForContinue()) return { action: "handled" };
|
|
651
|
+
ctx.ui.notify(message, "warning");
|
|
652
|
+
restoreInputStatus();
|
|
653
|
+
return { action: "continue" };
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
const initialSessionSnoozed = isSessionPracticeSnoozed(ctx, store, initialPolicy.practice);
|
|
657
|
+
const hasActiveTarget = resolvePracticeTargets({
|
|
658
|
+
targets: initialPolicy.practice.targets,
|
|
659
|
+
patterns: store.listKnownPatterns(),
|
|
660
|
+
ignoredPatternKeys: new Set(initialPolicy.settings.ignoredPatternKeys),
|
|
661
|
+
ignoredCategories: new Set(initialPolicy.settings.ignoredCategories),
|
|
662
|
+
}).some((target) => target.coachingEnabled);
|
|
663
|
+
if (!hasValidConfiguration(initialPolicy.settings, ctx)
|
|
664
|
+
|| !hasActiveTarget
|
|
665
|
+
|| !isCoachingEligible({
|
|
666
|
+
source: event.source,
|
|
667
|
+
idle: true,
|
|
668
|
+
textOnly: true,
|
|
669
|
+
collectionEligible: true,
|
|
670
|
+
sessionSnoozed: initialSessionSnoozed,
|
|
671
|
+
now: dependencies.now(),
|
|
672
|
+
policy: initialPolicy,
|
|
673
|
+
})) {
|
|
674
|
+
if (!clearForContinue()) return { action: "handled" };
|
|
675
|
+
if (hasValidConfiguration(initialPolicy.settings, ctx)) queueBackground(initialPolicy);
|
|
676
|
+
return { action: "continue" };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const commitFence = fenceFromPolicy(initialPolicy);
|
|
680
|
+
publishStatus(ctx, { kind: "practice-check" });
|
|
681
|
+
const attemptController = new AbortController();
|
|
682
|
+
coachingControllers.add(attemptController);
|
|
683
|
+
let successfulResult: Awaited<ReturnType<Analyzer["analyze"]>> | undefined;
|
|
684
|
+
let checkPolicy = initialPolicy;
|
|
685
|
+
let checkSessionSnoozed = initialSessionSnoozed;
|
|
686
|
+
let analyzerChangeObserved = false;
|
|
687
|
+
let policyReadFailed = false;
|
|
688
|
+
let coachingUiFailed = false;
|
|
689
|
+
let technicalFailureMessage = practiceFailureMessage("error");
|
|
690
|
+
const checkPromise: Promise<CoachingCheckResult> = (async () => {
|
|
691
|
+
try {
|
|
692
|
+
const context = selectPracticeAnalysisContext(initialPolicy.practice.targets, store.listKnownPatterns());
|
|
693
|
+
const outcome = await getWorker(ctx, store).analyzeForeground({
|
|
694
|
+
analyzer: createAnalyzer(ctx, store, initialPolicy.settings),
|
|
695
|
+
prompt,
|
|
696
|
+
patterns: context.patterns,
|
|
697
|
+
selectedTargets: context.targetDescriptors,
|
|
698
|
+
deadline: foregroundCoordinatorDeadline,
|
|
699
|
+
signal: attemptController.signal,
|
|
700
|
+
abortGraceMs: PRACTICE_CHECK_ABORT_GRACE_MS,
|
|
701
|
+
authorize: async () => {
|
|
702
|
+
let fresh;
|
|
703
|
+
try {
|
|
704
|
+
fresh = await store.getFreshPolicySnapshot(foregroundCoordinatorDeadline, attemptController.signal);
|
|
705
|
+
} catch (error) {
|
|
706
|
+
policyReadFailed = true;
|
|
707
|
+
technicalFailureMessage = "Sent without practice check — policy unavailable.";
|
|
708
|
+
throw error;
|
|
709
|
+
}
|
|
710
|
+
const sessionSnoozed = isSessionPracticeSnoozed(ctx, store, fresh.practice);
|
|
711
|
+
checkPolicy = fresh;
|
|
712
|
+
checkSessionSnoozed = sessionSnoozed;
|
|
713
|
+
backgroundPolicy = fresh;
|
|
714
|
+
const revalidation = revalidateCoachingPolicy(
|
|
715
|
+
initialPolicy,
|
|
716
|
+
fresh,
|
|
717
|
+
initialSessionSnoozed,
|
|
718
|
+
sessionSnoozed,
|
|
719
|
+
);
|
|
720
|
+
if (revalidation === "analytics-disabled") backgroundAllowed = false;
|
|
721
|
+
return revalidation === "unchanged"
|
|
722
|
+
&& isCoachingEligible({
|
|
723
|
+
source: event.source,
|
|
724
|
+
idle: true,
|
|
725
|
+
textOnly: true,
|
|
726
|
+
collectionEligible: true,
|
|
727
|
+
sessionSnoozed,
|
|
728
|
+
now: dependencies.now(),
|
|
729
|
+
policy: fresh,
|
|
730
|
+
});
|
|
731
|
+
},
|
|
732
|
+
});
|
|
733
|
+
if (outcome.kind !== "success") {
|
|
734
|
+
if (!policyReadFailed && !coachingUiFailed) technicalFailureMessage = practiceFailureMessage(outcome.kind);
|
|
735
|
+
return { kind: "failure" };
|
|
736
|
+
}
|
|
737
|
+
successfulResult = outcome.result;
|
|
738
|
+
try {
|
|
739
|
+
checkPolicy = await store.getFreshPolicySnapshot(foregroundDeadline, attemptController.signal);
|
|
740
|
+
} catch {
|
|
741
|
+
technicalFailureMessage = "Sent without practice check — policy unavailable.";
|
|
742
|
+
return { kind: "failure" };
|
|
743
|
+
}
|
|
744
|
+
checkSessionSnoozed = isSessionPracticeSnoozed(ctx, store, checkPolicy.practice);
|
|
745
|
+
backgroundPolicy = checkPolicy;
|
|
746
|
+
const revalidation = revalidateCoachingPolicy(
|
|
747
|
+
initialPolicy,
|
|
748
|
+
checkPolicy,
|
|
749
|
+
initialSessionSnoozed,
|
|
750
|
+
checkSessionSnoozed,
|
|
751
|
+
);
|
|
752
|
+
analyzerChangeObserved = revalidation === "analyzer-changed";
|
|
753
|
+
if (revalidation === "analytics-disabled"
|
|
754
|
+
|| revalidation === "analyzer-changed"
|
|
755
|
+
|| revalidation === "gate-changed") {
|
|
756
|
+
technicalFailureMessage = "Sent without practice check — practice settings changed.";
|
|
757
|
+
if (revalidation === "analytics-disabled") backgroundAllowed = false;
|
|
758
|
+
return { kind: "failure" };
|
|
759
|
+
}
|
|
760
|
+
const gateStillEligible = isCoachingEligible({
|
|
761
|
+
source: event.source,
|
|
762
|
+
idle: true,
|
|
763
|
+
textOnly: true,
|
|
764
|
+
collectionEligible: true,
|
|
765
|
+
sessionSnoozed: checkSessionSnoozed,
|
|
766
|
+
now: dependencies.now(),
|
|
767
|
+
policy: checkPolicy,
|
|
768
|
+
});
|
|
769
|
+
const matches = gateStillEligible
|
|
770
|
+
? selectedCoachingMistakes(outcome.result, checkPolicy.settings, checkPolicy.practice)
|
|
771
|
+
: [];
|
|
772
|
+
return matches.length === 0
|
|
773
|
+
? { kind: "clean" }
|
|
774
|
+
: { kind: "matches", mistakes: matches, targets: checkPolicy.practice.targets };
|
|
775
|
+
} catch {
|
|
776
|
+
return { kind: "failure" };
|
|
777
|
+
}
|
|
778
|
+
})();
|
|
779
|
+
|
|
780
|
+
let snoozePersisted = false;
|
|
781
|
+
const persistSnooze = async (action: CoachingSnoozeDecision): Promise<void> => {
|
|
782
|
+
snoozePersisted = true;
|
|
783
|
+
if (action === "snooze-session") {
|
|
784
|
+
try {
|
|
785
|
+
practiceSessionSnooze.snooze(
|
|
786
|
+
sessionFile(ctx),
|
|
787
|
+
checkPolicy.practice.epoch,
|
|
788
|
+
(customType, data) => pi.appendEntry(customType, data),
|
|
789
|
+
);
|
|
790
|
+
} catch {
|
|
791
|
+
ctx.ui.notify("Sent once; conversation snooze was not activated.", "warning");
|
|
792
|
+
}
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
const operationDeadline = Date.now() + 1_000;
|
|
796
|
+
const mutation = store.snoozePracticeForFiveHours(
|
|
797
|
+
checkPolicy.practice.revision,
|
|
798
|
+
operationDeadline,
|
|
799
|
+
dependencies.now(),
|
|
800
|
+
).then((activated) => ({ kind: "result" as const, activated }), () => ({ kind: "error" as const }));
|
|
801
|
+
const first = await Promise.race([
|
|
802
|
+
mutation,
|
|
803
|
+
new Promise<{ kind: "deadline" }>((resolve) => setTimeout(
|
|
804
|
+
() => resolve({ kind: "deadline" }),
|
|
805
|
+
Math.max(0, operationDeadline - Date.now()),
|
|
806
|
+
)),
|
|
807
|
+
]);
|
|
808
|
+
if (first.kind === "result" && first.activated) return;
|
|
809
|
+
|
|
810
|
+
// Mutation and confirmation share one absolute second. Never manufacture a second deadline.
|
|
811
|
+
if (Date.now() < operationDeadline) {
|
|
812
|
+
try {
|
|
813
|
+
const authoritative = await store.getFreshPolicySnapshot(operationDeadline);
|
|
814
|
+
if ((authoritative.practice.snoozedUntil ?? 0) > dependencies.now()) return;
|
|
815
|
+
ctx.ui.notify("Sent once; 5-hour snooze was not activated.", "warning");
|
|
816
|
+
return;
|
|
817
|
+
} catch { /* Remaining deadline could not establish authoritative state. */ }
|
|
818
|
+
}
|
|
819
|
+
ctx.ui.notify("Sent once; snooze state unknown — use /fluency practice resume.", "warning");
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
let decision: CoachingOverlayDecision;
|
|
823
|
+
try {
|
|
824
|
+
decision = await (dependencies.showCoaching ?? showCoachingOverlay)(
|
|
825
|
+
ctx,
|
|
826
|
+
checkPromise,
|
|
827
|
+
attemptController.signal,
|
|
828
|
+
persistSnooze,
|
|
829
|
+
);
|
|
830
|
+
} catch {
|
|
831
|
+
coachingUiFailed = true;
|
|
832
|
+
decision = "technical-failure";
|
|
833
|
+
}
|
|
834
|
+
if (decision === "edit") {
|
|
835
|
+
attemptController.abort();
|
|
836
|
+
await checkPromise;
|
|
837
|
+
coachingControllers.delete(attemptController);
|
|
838
|
+
ctx.ui.notify("Not sent — draft remains in editor.", "info");
|
|
839
|
+
restoreInputStatus();
|
|
840
|
+
return { action: "handled" };
|
|
841
|
+
}
|
|
842
|
+
if (decision === "send-unchecked" || decision === "technical-failure") {
|
|
843
|
+
const shutdownInterrupted = shuttingDown;
|
|
844
|
+
attemptController.abort();
|
|
845
|
+
await checkPromise;
|
|
846
|
+
coachingControllers.delete(attemptController);
|
|
847
|
+
if (shutdownInterrupted || shuttingDown) {
|
|
848
|
+
backgroundAllowed = false;
|
|
849
|
+
return failOpen(practiceFailureMessage("shutdown"));
|
|
850
|
+
}
|
|
851
|
+
return failOpen(decision === "send-unchecked"
|
|
852
|
+
? "Sent without practice check — analyzer cancelled."
|
|
853
|
+
: technicalFailureMessage);
|
|
854
|
+
}
|
|
855
|
+
coachingControllers.delete(attemptController);
|
|
856
|
+
|
|
857
|
+
if ((decision === "snooze-session" || decision === "snooze-five-hours") && !snoozePersisted) {
|
|
858
|
+
await persistSnooze(decision);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
if (!clearForContinue()) return { action: "handled" };
|
|
862
|
+
|
|
863
|
+
if (successfulResult !== undefined) {
|
|
864
|
+
let finalPolicy = checkPolicy;
|
|
865
|
+
let finalSessionSnoozed = checkSessionSnoozed;
|
|
866
|
+
try {
|
|
867
|
+
finalPolicy = await store.getFreshPolicySnapshot(Date.now() + 1_000);
|
|
868
|
+
finalSessionSnoozed = isSessionPracticeSnoozed(ctx, store, finalPolicy.practice);
|
|
869
|
+
backgroundPolicy = finalPolicy;
|
|
870
|
+
} catch { /* Conditional store fence remains authoritative. */ }
|
|
871
|
+
const revalidation = revalidateCoachingPolicy(
|
|
872
|
+
initialPolicy,
|
|
873
|
+
finalPolicy,
|
|
874
|
+
initialSessionSnoozed,
|
|
875
|
+
finalSessionSnoozed,
|
|
876
|
+
);
|
|
877
|
+
const reuse = analysisReuseAction("continue", revalidation);
|
|
878
|
+
if (reuse === "commit-foreground") {
|
|
879
|
+
let tracked!: Promise<void>;
|
|
880
|
+
tracked = store.conditionalAppendAnalysis(commitFence, prompt, successfulResult)
|
|
881
|
+
.then((result) => {
|
|
882
|
+
if (result === "analyzer-stale" && !shuttingDown) queueBackground(backgroundPolicy);
|
|
883
|
+
else if (result === "committed" && !shuttingDown) publishProgress(ctx, store);
|
|
884
|
+
})
|
|
885
|
+
.catch((error) => {
|
|
886
|
+
if (!shuttingDown) setError(ctx, "store", error);
|
|
887
|
+
})
|
|
888
|
+
.finally(() => scheduledCommits.delete(tracked));
|
|
889
|
+
scheduledCommits.add(tracked);
|
|
890
|
+
} else if (reuse === "queue-background" || analyzerChangeObserved) {
|
|
891
|
+
queueBackground(backgroundPolicy);
|
|
892
|
+
}
|
|
893
|
+
} else {
|
|
894
|
+
queueBackground(backgroundPolicy);
|
|
393
895
|
}
|
|
896
|
+
restoreInputStatus();
|
|
897
|
+
return { action: "continue" };
|
|
394
898
|
});
|
|
395
899
|
|
|
396
900
|
pi.on("agent_settled", (_event, ctx) => {
|
|
@@ -417,7 +921,14 @@ function registerHandlers(pi: ExtensionAPI, dependencies: ResolvedDependencies):
|
|
|
417
921
|
const shutdownCtx = ctxRef;
|
|
418
922
|
if (shutdownCtx) clearStatus(shutdownCtx);
|
|
419
923
|
overlayController?.abort();
|
|
420
|
-
|
|
924
|
+
for (const controller of coachingControllers) controller.abort();
|
|
925
|
+
const commits = Promise.allSettled([...scheduledCommits]);
|
|
926
|
+
await Promise.all([
|
|
927
|
+
workerRef?.shutdown(),
|
|
928
|
+
overlayOpen,
|
|
929
|
+
Promise.race([commits, new Promise<void>((resolve) => setTimeout(resolve, 100))]),
|
|
930
|
+
]);
|
|
931
|
+
coachingControllers.clear();
|
|
421
932
|
if (shutdownCtx) clearStatus(shutdownCtx);
|
|
422
933
|
workerRef = undefined;
|
|
423
934
|
overlayOpen = undefined;
|