pi-studio 0.9.53 → 0.9.55

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,181 @@
1
+ import { watchFile, unwatchFile } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import {
4
+ createStudioDiskRevision,
5
+ normalizeStudioDiskRevision,
6
+ readStudioDiskFileSnapshot,
7
+ } from "./studio-disk-revisions.js";
8
+
9
+ export const STUDIO_FILE_WATCH_INTERVAL_MS = 300;
10
+ export const STUDIO_FILE_WATCH_DEBOUNCE_MS = 150;
11
+
12
+ function errorMessage(error) {
13
+ return error instanceof Error ? error.message : String(error);
14
+ }
15
+
16
+ function normalizeComparablePath(filePath) {
17
+ const normalized = resolve(filePath);
18
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
19
+ }
20
+
21
+ function pathsMatch(left, right) {
22
+ return normalizeComparablePath(left) === normalizeComparablePath(right);
23
+ }
24
+
25
+ export function createStudioFileWatcher(options) {
26
+ const filePath = typeof options?.filePath === "string" ? options.filePath.trim() : "";
27
+ if (!filePath) throw new Error("Missing watched file path.");
28
+ const canonicalPath = resolve(filePath);
29
+ const requestedIntervalMs = Number(options?.intervalMs);
30
+ const requestedDebounceMs = Number(options?.debounceMs);
31
+ const intervalMs = Math.max(20, Math.floor(Number.isFinite(requestedIntervalMs) ? requestedIntervalMs : STUDIO_FILE_WATCH_INTERVAL_MS));
32
+ const debounceMs = Math.max(0, Math.floor(Number.isFinite(requestedDebounceMs) ? requestedDebounceMs : STUDIO_FILE_WATCH_DEBOUNCE_MS));
33
+ const readSnapshot = typeof options?.readSnapshot === "function" ? options.readSnapshot : readStudioDiskFileSnapshot;
34
+ const onUpdate = typeof options?.onUpdate === "function" ? options.onUpdate : () => undefined;
35
+ const onError = typeof options?.onError === "function" ? options.onError : () => undefined;
36
+ const onRecovered = typeof options?.onRecovered === "function" ? options.onRecovered : () => undefined;
37
+ let lastRevision = normalizeStudioDiskRevision(options?.initialRevision);
38
+ let lastError = null;
39
+ let generation = 0;
40
+ let debounceTimer = null;
41
+ let errorRetryTimer = null;
42
+ let startupReconcileTimer = null;
43
+ let refreshInFlight = null;
44
+ let refreshQueued = false;
45
+ let closed = false;
46
+
47
+ const readStableSnapshot = async () => {
48
+ const snapshot = await readSnapshot(canonicalPath);
49
+ if (!snapshot || typeof snapshot !== "object" || !pathsMatch(snapshot.path, canonicalPath)) {
50
+ throw new Error("The watched file location now resolves somewhere else.");
51
+ }
52
+ const revision = normalizeStudioDiskRevision(snapshot.revision)
53
+ || createStudioDiskRevision(snapshot.buffer ?? snapshot.text ?? "");
54
+ const text = typeof snapshot.text === "string"
55
+ ? snapshot.text
56
+ : Buffer.from(snapshot.buffer ?? "").toString("utf8");
57
+ return { ...snapshot, path: canonicalPath, text, revision };
58
+ };
59
+
60
+ const scheduleErrorRetry = () => {
61
+ if (closed || errorRetryTimer) return;
62
+ errorRetryTimer = setTimeout(() => {
63
+ errorRetryTimer = null;
64
+ void refresh().catch(() => {
65
+ // onError owns watcher failures; never create an unhandled rejection.
66
+ });
67
+ }, intervalMs);
68
+ };
69
+
70
+ const performRefresh = async () => {
71
+ try {
72
+ const snapshot = await readStableSnapshot();
73
+ if (closed) return false;
74
+ if (errorRetryTimer) {
75
+ clearTimeout(errorRetryTimer);
76
+ errorRetryTimer = null;
77
+ }
78
+ const recovered = lastError !== null;
79
+ const changed = snapshot.revision !== lastRevision;
80
+ if (changed) {
81
+ const nextGeneration = generation + 1;
82
+ await onUpdate(snapshot, { generation: nextGeneration, recovered });
83
+ if (closed) return false;
84
+ generation = nextGeneration;
85
+ lastRevision = snapshot.revision;
86
+ // Reconcile after publication in case a save landed while an earlier
87
+ // read or client update was in flight and filesystem events coalesced.
88
+ refreshQueued = true;
89
+ }
90
+ if (recovered) {
91
+ lastError = null;
92
+ await onRecovered(snapshot, { generation, changed });
93
+ }
94
+ return changed;
95
+ } catch (error) {
96
+ if (closed) return false;
97
+ const message = errorMessage(error);
98
+ if (message !== lastError) {
99
+ lastError = message;
100
+ await onError(error, { generation: generation + 1, lastRevision });
101
+ }
102
+ scheduleErrorRetry();
103
+ return false;
104
+ }
105
+ };
106
+
107
+ const refresh = () => {
108
+ if (closed) return Promise.resolve(false);
109
+ refreshQueued = true;
110
+ if (refreshInFlight) return refreshInFlight;
111
+ let changed = false;
112
+ const loop = (async () => {
113
+ while (!closed && refreshQueued) {
114
+ refreshQueued = false;
115
+ changed = await performRefresh() || changed;
116
+ }
117
+ return changed;
118
+ })();
119
+ const tracked = loop.finally(() => {
120
+ if (refreshInFlight === tracked) refreshInFlight = null;
121
+ });
122
+ refreshInFlight = tracked;
123
+ return tracked;
124
+ };
125
+
126
+ const schedule = () => {
127
+ if (closed) return;
128
+ if (debounceTimer) clearTimeout(debounceTimer);
129
+ debounceTimer = setTimeout(() => {
130
+ debounceTimer = null;
131
+ void refresh().catch(() => {
132
+ // onError owns watcher failures; never create an unhandled rejection.
133
+ });
134
+ }, debounceMs);
135
+ };
136
+
137
+ const listener = () => schedule();
138
+ watchFile(canonicalPath, { interval: intervalMs }, listener);
139
+ // StatWatcher establishes its first comparison baseline asynchronously. Reconcile
140
+ // once after that window so an atomic replacement immediately after subscribe
141
+ // cannot become the unseen baseline and wait forever for another filesystem event.
142
+ startupReconcileTimer = setTimeout(() => {
143
+ startupReconcileTimer = null;
144
+ void refresh().catch(() => {
145
+ // onError owns watcher failures; never create an unhandled rejection.
146
+ });
147
+ }, intervalMs);
148
+
149
+ return Object.freeze({
150
+ filePath: canonicalPath,
151
+ refresh,
152
+ async close() {
153
+ if (closed) return;
154
+ closed = true;
155
+ refreshQueued = false;
156
+ if (debounceTimer) {
157
+ clearTimeout(debounceTimer);
158
+ debounceTimer = null;
159
+ }
160
+ if (errorRetryTimer) {
161
+ clearTimeout(errorRetryTimer);
162
+ errorRetryTimer = null;
163
+ }
164
+ if (startupReconcileTimer) {
165
+ clearTimeout(startupReconcileTimer);
166
+ startupReconcileTimer = null;
167
+ }
168
+ unwatchFile(canonicalPath, listener);
169
+ if (refreshInFlight) await refreshInFlight.catch(() => undefined);
170
+ },
171
+ get revision() {
172
+ return lastRevision;
173
+ },
174
+ get error() {
175
+ return lastError;
176
+ },
177
+ get generation() {
178
+ return generation;
179
+ },
180
+ });
181
+ }
@@ -1,5 +1,16 @@
1
+ import { clampThinkingLevel, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
2
+
1
3
  export const STUDIO_SIDE_QUESTION_FOCUS_MAX_CHARS = 60_000;
2
4
  export const STUDIO_SIDE_QUESTION_QUESTION_MAX_CHARS = 12_000;
5
+ export const STUDIO_SIDE_QUESTION_THINKING_LEVELS = Object.freeze([
6
+ "off",
7
+ "minimal",
8
+ "low",
9
+ "medium",
10
+ "high",
11
+ "xhigh",
12
+ "max",
13
+ ]);
3
14
 
4
15
  export function normalizeStudioSideQuestionFocusKind(value) {
5
16
  const normalized = String(value ?? "").trim().toLowerCase();
@@ -20,10 +31,28 @@ export function normalizeStudioSideQuestionGatherScope(value) {
20
31
 
21
32
  export function normalizeStudioSideQuestionThinking(value) {
22
33
  const normalized = String(value ?? "").trim().toLowerCase();
23
- if (normalized === "off" || normalized === "minimal" || normalized === "low" || normalized === "medium" || normalized === "high") {
24
- return normalized;
34
+ return STUDIO_SIDE_QUESTION_THINKING_LEVELS.includes(normalized) ? normalized : "low";
35
+ }
36
+
37
+ export function getStudioSideQuestionThinkingLevels(model) {
38
+ if (!model) return [...STUDIO_SIDE_QUESTION_THINKING_LEVELS];
39
+ try {
40
+ const supported = getSupportedThinkingLevels(model)
41
+ .filter((level) => STUDIO_SIDE_QUESTION_THINKING_LEVELS.includes(level));
42
+ return supported.length > 0 ? supported : ["off"];
43
+ } catch {
44
+ return [...STUDIO_SIDE_QUESTION_THINKING_LEVELS];
45
+ }
46
+ }
47
+
48
+ export function resolveStudioSideQuestionThinking(model, value) {
49
+ const requested = normalizeStudioSideQuestionThinking(value);
50
+ if (!model) return requested;
51
+ try {
52
+ return clampThinkingLevel(model, requested);
53
+ } catch {
54
+ return requested;
25
55
  }
26
- return "low";
27
56
  }
28
57
 
29
58
  function sanitizePromptContent(value) {
@@ -20,6 +20,9 @@ export function normalizeStudioWorkspaceRecoveryState(value) {
20
20
  if (!value || typeof value !== "object" || value.version !== 1 || typeof value.text !== "string") return null;
21
21
  if (value.text.length > STUDIO_WORKSPACE_STATE_MAX_TEXT_CHARS) return null;
22
22
  const sourceState = value.sourceState && typeof value.sourceState === "object" ? value.sourceState : {};
23
+ const diskRevision = typeof value.diskRevision === "string" && /^sha256:[a-f0-9]{64}$/i.test(value.diskRevision.trim())
24
+ ? value.diskRevision.trim().toLowerCase()
25
+ : null;
23
26
  return {
24
27
  version: 1,
25
28
  savedAt: Math.max(0, finiteNumber(value.savedAt)),
@@ -29,6 +32,7 @@ export function normalizeStudioWorkspaceRecoveryState(value) {
29
32
  path: boundedString(sourceState.path, 16_384) || null,
30
33
  draftId: boundedString(sourceState.draftId, 256) || null,
31
34
  },
35
+ diskRevision,
32
36
  resourceDir: boundedString(value.resourceDir, 16_384),
33
37
  editorView: boundedString(value.editorView, 100),
34
38
  rightView: boundedString(value.rightView, 100),