pi-studio 0.9.63 → 0.10.1
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/BUFFER-DESIGN.md +197 -0
- package/CHANGELOG.md +31 -0
- package/PRERELEASE-CHECKLIST.md +100 -0
- package/README.md +110 -193
- package/ROADMAP.md +63 -6
- package/WORKFLOW.md +32 -2
- package/assets/screenshots/dark-workspace.png +0 -0
- package/assets/screenshots/light-workspace.png +0 -0
- package/assets/screenshots/theme-zen-code.png +0 -0
- package/client/studio-client.js +6694 -942
- package/client/studio-editor-draft-helpers.js +162 -0
- package/client/studio-navigation-helpers.js +31 -5
- package/client/studio.css +460 -12
- package/index.ts +1178 -79
- package/package.json +5 -3
- package/shared/STUDIO_BUFFER_FOUNDATION.md +164 -0
- package/shared/studio-buffer-client.js +663 -0
- package/shared/studio-buffer-decisions.js +129 -0
- package/shared/studio-buffer-lineage.js +48 -0
- package/shared/studio-buffer-recovery-panel.js +132 -0
- package/shared/studio-buffer-recovery.js +181 -0
- package/shared/studio-buffer-server.js +700 -0
- package/shared/studio-buffer-store.js +227 -0
- package/shared/studio-buffer-switching.js +53 -0
- package/shared/studio-disk-revisions.js +15 -0
- package/shared/studio-document-entrypoints.js +38 -0
- package/shared/studio-document-escrow.js +147 -0
- package/shared/studio-document-file-claims.js +215 -0
- package/shared/studio-document-host-client.js +268 -0
- package/shared/studio-document-hosting.js +319 -0
- package/shared/studio-document-launch-client.js +153 -0
- package/shared/studio-document-open-client.js +96 -0
- package/shared/studio-document-open-storage.js +21 -0
- package/shared/studio-document-paths.js +29 -0
- package/shared/studio-editor-mode.js +48 -0
- package/shared/studio-linked-reader-ui.js +232 -0
- package/shared/studio-linked-reader.js +110 -0
- package/shared/studio-metadata-write-order.js +35 -0
- package/shared/studio-read-only-media.js +24 -0
- package/shared/studio-resource-grants.js +3 -0
- package/shared/studio-run-lifecycle.js +47 -0
- package/shared/studio-system-theme.js +9 -0
- package/assets/screenshots/dark-code-mode.png +0 -0
- package/assets/screenshots/dark-critique.png +0 -0
- package/assets/screenshots/dark-fenced-code.png +0 -0
- package/assets/screenshots/dark-focus-mode.png +0 -0
- package/assets/screenshots/dark-julia-fenced.png +0 -0
- package/assets/screenshots/dark-math.png +0 -0
- package/assets/screenshots/dark-mermaid.png +0 -0
- package/assets/screenshots/dark-zen.png +0 -0
- package/assets/screenshots/light-code-mode.png +0 -0
- package/assets/screenshots/light-fenced-code.png +0 -0
- package/assets/screenshots/light-math.png +0 -0
|
@@ -57,8 +57,170 @@
|
|
|
57
57
|
return Boolean(text.trim() && text !== responseText && !submitted);
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// Informational origin only: never a file grant, saved receipt or run proof.
|
|
61
|
+
function normalizeSourceProvenance(value) {
|
|
62
|
+
if (!value || typeof value !== "object" || Array.isArray(value)
|
|
63
|
+
|| Object.keys(value).some(key => !["version", "kind", "responseId", "responseNumber", "annotated"].includes(key))
|
|
64
|
+
|| value.version !== 1 || value.kind !== "response" || typeof value.annotated !== "boolean"
|
|
65
|
+
|| !(value.responseId === null || (typeof value.responseId === "string" && value.responseId.length > 0 && value.responseId.length <= 256 && !value.responseId.includes("\0")))
|
|
66
|
+
|| !(value.responseNumber === null || (Number.isSafeInteger(value.responseNumber) && value.responseNumber > 0 && value.responseNumber <= 1_000_000_000))) return null;
|
|
67
|
+
return { version: 1, kind: "response", responseId: value.responseId, responseNumber: value.responseNumber, annotated: value.annotated };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function createResponseDraftProvenance({ id, index, annotated = false }) {
|
|
71
|
+
return Object.freeze({ version: 1, kind: "response",
|
|
72
|
+
responseId: typeof id === "string" && id.length > 0 && id.length <= 256 && !id.includes("\0") ? id : null,
|
|
73
|
+
responseNumber: Number.isSafeInteger(index) && index >= 0 && index < 1_000_000_000 ? index + 1 : null,
|
|
74
|
+
annotated: annotated === true });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function describeSourceProvenance(value) {
|
|
78
|
+
const p = normalizeSourceProvenance(value);
|
|
79
|
+
return p ? "Loaded from response" + (p.responseNumber === null ? "" : " " + p.responseNumber)
|
|
80
|
+
+ (p.annotated ? " for annotation." : ".") : "";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function safeDraftStem(value) {
|
|
84
|
+
let stem = String(value || "").normalize("NFKC")
|
|
85
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1").replace(/[`*_~]/g, "")
|
|
86
|
+
.replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").toLowerCase();
|
|
87
|
+
let bytes = 0;
|
|
88
|
+
stem = Array.from(stem).slice(0, 80).filter(character => {
|
|
89
|
+
const point = character.codePointAt(0), size = point > 0xffff ? 4 : point > 0x7ff ? 3 : point > 0x7f ? 2 : 1;
|
|
90
|
+
bytes += size; return bytes <= 160;
|
|
91
|
+
}).join("").replace(/-+$/g, "");
|
|
92
|
+
if (/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(stem)) stem = "document-" + stem;
|
|
93
|
+
return stem || "draft";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function firstDraftHeading(text) {
|
|
97
|
+
const lines = String(text || "").slice(0, 32_000).split(/\r?\n/, 256);
|
|
98
|
+
let frontMatter = /^---\s*$/.test(lines[0]), fence = null;
|
|
99
|
+
for (let i = frontMatter ? 1 : 0; i < lines.length; i++) {
|
|
100
|
+
const line = lines[i];
|
|
101
|
+
if (frontMatter) { if (/^(?:---|\.\.\.)\s*$/.test(line)) frontMatter = false; continue; }
|
|
102
|
+
const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
|
103
|
+
if (fence) { if (marker && marker[1][0] === fence[0] && marker[1].length >= fence.length && !line.slice(marker[0].length).trim()) fence = null; continue; }
|
|
104
|
+
if (marker) { fence = marker[1]; continue; }
|
|
105
|
+
const heading = /^ {0,3}#{1,6}\s+(.+?)\s*#*\s*$/.exec(line);
|
|
106
|
+
if (heading) return heading[1];
|
|
107
|
+
if (line.trim() && !/^(?:\s{4}|\t|\s*[-*>])/.test(line) && /^ {0,3}(?:=+|-+)\s*$/.test(lines[i + 1] || "")) return line;
|
|
108
|
+
}
|
|
109
|
+
return "";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function buildDraftSaveFilename({ sourceState = {}, text = "", annotated = false }) {
|
|
113
|
+
const p = normalizeSourceProvenance(sourceState.provenance);
|
|
114
|
+
if (p) return "response" + (p.responseNumber === null ? "" : "-" + p.responseNumber)
|
|
115
|
+
+ (p.annotated || annotated ? "-annotated" : "") + ".md";
|
|
116
|
+
// Imported HTML is authored HTML, not Markdown. CSS/script/body lines may
|
|
117
|
+
// resemble Markdown headings; retain the informational HTML extension first.
|
|
118
|
+
if (sourceState.source === "import" || sourceState.source === "upload") {
|
|
119
|
+
const name = String(sourceState.label || "").replace(/^(?:upload|imported copy):\s*/i, "").split(/[/\\]/).at(-1) || "";
|
|
120
|
+
const htmlName = /^(.+)\.(html?)$/i.exec(name);
|
|
121
|
+
if (htmlName) return safeDraftStem(htmlName[1]) + (annotated ? "-annotated" : "") + "." + htmlName[2];
|
|
122
|
+
}
|
|
123
|
+
const heading = firstDraftHeading(text);
|
|
124
|
+
if (heading) return safeDraftStem(heading) + (annotated ? "-annotated" : "") + ".md";
|
|
125
|
+
if (sourceState.source === "upload") {
|
|
126
|
+
const name = String(sourceState.label || "").replace(/^(?:upload|imported copy):\s*/i, "").split(/[/\\]/).at(-1) || "";
|
|
127
|
+
const match = /^(.+)\.([a-z0-9]{1,12})$/i.exec(name);
|
|
128
|
+
return safeDraftStem(match ? match[1] : name) + (annotated ? "-annotated" : "") + (match ? "." + match[2] : ".md");
|
|
129
|
+
}
|
|
130
|
+
return "draft" + (annotated ? "-annotated" : "") + ".md";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Informational UI only. SDK user-message receipts establish its baseline;
|
|
134
|
+
// it has no role in replacement consent, saving or submission authority.
|
|
135
|
+
function createPromptRunIndicatorTracker({ now = () => Date.now() } = {}) {
|
|
136
|
+
const pending = new Map(), records = new Map();
|
|
137
|
+
let sequence = 0, pendingChars = 0;
|
|
138
|
+
const validId = id => typeof id === "string" && id.length > 0 && id.length <= 256;
|
|
139
|
+
function discard(requestId) {
|
|
140
|
+
const item = pending.get(requestId);
|
|
141
|
+
if (!item) return false;
|
|
142
|
+
pendingChars -= item.text?.length || 0; pending.delete(requestId); return true;
|
|
143
|
+
}
|
|
144
|
+
return Object.freeze({
|
|
145
|
+
initialize(bufferId, fresh = false) {
|
|
146
|
+
if (!validId(bufferId) || records.has(bufferId)) return false;
|
|
147
|
+
records.set(bufferId, { phase: fresh ? "not-run" : "unknown", text: null, sentAt: null, sequence: 0 }); return true;
|
|
148
|
+
},
|
|
149
|
+
remember(requestId, bufferId, text) {
|
|
150
|
+
if (!validId(requestId) || !validId(bufferId) || typeof text !== "string" || pending.has(requestId)) return false;
|
|
151
|
+
const kept = text.length <= MAX_TEXT_CHARS ? text : null;
|
|
152
|
+
while (pending.size >= MAX_PENDING || pendingChars + (kept?.length || 0) > MAX_PENDING_TEXT_CHARS) {
|
|
153
|
+
const id = pending.keys().next().value, lost = pending.get(id), previous = records.get(lost.bufferId);
|
|
154
|
+
if (!previous || previous.sequence < lost.sequence) records.set(lost.bufferId, { phase: "unknown", text: null, sentAt: previous?.sentAt ?? null, sequence: lost.sequence });
|
|
155
|
+
discard(id);
|
|
156
|
+
}
|
|
157
|
+
pending.set(requestId, { bufferId, text: kept, sequence: ++sequence }); pendingChars += kept?.length || 0; return true;
|
|
158
|
+
},
|
|
159
|
+
submitted(requestId, sentAt) {
|
|
160
|
+
const item = pending.get(requestId); if (!item) return false;
|
|
161
|
+
discard(requestId); const previous = records.get(item.bufferId);
|
|
162
|
+
if (previous && previous.sequence >= item.sequence) return false;
|
|
163
|
+
records.set(item.bufferId, { phase: item.text === null ? "unknown" : "sent", text: item.text,
|
|
164
|
+
sentAt: typeof sentAt === "number" && Number.isFinite(sentAt) && sentAt > 0 ? sentAt : now(), sequence: item.sequence, requestId }); return true;
|
|
165
|
+
},
|
|
166
|
+
// A settled Stop or failure belongs only to the Run that sent this text.
|
|
167
|
+
// requestIds lists every input receipt accepted during that Run, steering included.
|
|
168
|
+
settled(requestIds, outcome, settledAt) {
|
|
169
|
+
const ids = (Array.isArray(requestIds) ? requestIds : [requestIds]).filter(validId);
|
|
170
|
+
if (!ids.length || !["stopped", "failed"].includes(outcome)) return false;
|
|
171
|
+
for (const [bufferId, item] of records) {
|
|
172
|
+
if (item.phase !== "sent" || !ids.includes(item.requestId)) continue;
|
|
173
|
+
records.set(bufferId, { ...item, outcome,
|
|
174
|
+
settledAt: typeof settledAt === "number" && Number.isFinite(settledAt) && settledAt > 0 ? settledAt : now() });
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
return false;
|
|
178
|
+
},
|
|
179
|
+
uncertain(requestId) {
|
|
180
|
+
const item = pending.get(requestId); if (!item) return false;
|
|
181
|
+
discard(requestId); const previous = records.get(item.bufferId);
|
|
182
|
+
if (previous && previous.sequence >= item.sequence) return false;
|
|
183
|
+
records.set(item.bufferId, { phase: "unknown", text: null, sentAt: previous?.sentAt ?? null, sequence: item.sequence }); return true;
|
|
184
|
+
},
|
|
185
|
+
state(bufferId, text) {
|
|
186
|
+
const item = records.get(bufferId);
|
|
187
|
+
if (!item) return { phase: "unknown", sentAt: null };
|
|
188
|
+
if (item.phase !== "sent") return { phase: item.phase, sentAt: item.sentAt };
|
|
189
|
+
if (item.text !== text) return { phase: "edited", sentAt: item.sentAt };
|
|
190
|
+
return item.outcome ? { phase: item.outcome, sentAt: item.sentAt, settledAt: item.settledAt } : { phase: "sent", sentAt: item.sentAt };
|
|
191
|
+
},
|
|
192
|
+
discard,
|
|
193
|
+
clearPending() {
|
|
194
|
+
for (const item of pending.values()) {
|
|
195
|
+
const previous = records.get(item.bufferId);
|
|
196
|
+
if (!previous || previous.sequence < item.sequence) records.set(item.bufferId, { phase: "unknown", text: null, sentAt: previous?.sentAt ?? null, sequence: item.sequence });
|
|
197
|
+
}
|
|
198
|
+
pending.clear(); pendingChars = 0;
|
|
199
|
+
},
|
|
200
|
+
// Preserve status/time without duplicating draft text in recovery storage.
|
|
201
|
+
// Exact content comparisons intentionally become unknown after a reload.
|
|
202
|
+
snapshot() { return { version: 1, records: [...records].map(([bufferId, item]) => ({ bufferId,
|
|
203
|
+
phase: item.phase === "sent" || [...pending.values()].some(p => p.bufferId === bufferId) ? "unknown" : item.phase,
|
|
204
|
+
sentAt: item.sentAt, sequence: item.sequence })) }; },
|
|
205
|
+
restore(value, bufferId) {
|
|
206
|
+
if (!validId(bufferId) || value?.version !== 1 || !Array.isArray(value.records) || value.records.length > 2) return false;
|
|
207
|
+
const item = value.records.find(r => r?.bufferId === bufferId);
|
|
208
|
+
if (!item || !["not-run", "unknown"].includes(item.phase)
|
|
209
|
+
|| !Number.isSafeInteger(item.sequence) || item.sequence < 0
|
|
210
|
+
|| (item.sentAt !== null && (typeof item.sentAt !== "number" || !Number.isFinite(item.sentAt) || item.sentAt <= 0))
|
|
211
|
+
|| (item.phase === "not-run" && (item.sequence !== 0 || item.sentAt !== null))) return false;
|
|
212
|
+
// A stale not-run snapshot may predate a receipt whose storage write
|
|
213
|
+
// failed. Only fresh in-memory initialization can assert Not run.
|
|
214
|
+
records.set(bufferId, { phase: "unknown", text: null, sentAt: item.sentAt, sequence: item.sequence });
|
|
215
|
+
sequence = Math.max(sequence, item.sequence); return true;
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
60
220
|
globalThis.PiStudioEditorDraftHelpers = Object.freeze({
|
|
61
221
|
createSubmittedEditorDraftTracker,
|
|
222
|
+
normalizeSourceProvenance, createResponseDraftProvenance, describeSourceProvenance, buildDraftSaveFilename,
|
|
223
|
+
createPromptRunIndicatorTracker,
|
|
62
224
|
needsDraftReplacementConfirmation,
|
|
63
225
|
});
|
|
64
226
|
})();
|
|
@@ -224,6 +224,25 @@
|
|
|
224
224
|
return parsed.pathname + parsed.search + parsed.hash;
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
+
// A separate viewer protocol, not permission to navigate an editable launch
|
|
228
|
+
// to arbitrary endpoints. Resources are still authenticated/revalidated by
|
|
229
|
+
// their server routes. Raw SVG/HTML and root editing URLs are not viewers.
|
|
230
|
+
function normalizeStudioReadOnlyTarget(target, locationLike, token) {
|
|
231
|
+
if (typeof target !== "string" || !target || target.length > STUDIO_LAUNCH_TARGET_MAX_CHARS
|
|
232
|
+
|| target.includes("\\") || /[\u0000-\u001f\u007f]/.test(target)) throw new Error("Invalid read-only viewer target.");
|
|
233
|
+
const path = target.split(/[?#]/, 1)[0];
|
|
234
|
+
if (!["/image-viewer", "/pdf-resource", "/export-pdf"].includes(path)) throw new Error("Only Studio PDF/image viewers can use this launch.");
|
|
235
|
+
const base = new URL(locationLike.href), parsed = new URL(target, base);
|
|
236
|
+
if (!["http:", "https:"].includes(base.protocol) || parsed.origin !== base.origin || parsed.pathname !== path || parsed.username || parsed.password) throw new Error("Read-only viewers must stay on this Studio origin.");
|
|
237
|
+
const tokens = parsed.searchParams.getAll("token");
|
|
238
|
+
if (tokens.length !== 1 || tokens[0] !== token) throw new Error("Read-only viewer token did not match this session.");
|
|
239
|
+
const prepared = path === "/export-pdf", allowed = prepared ? ["token", "id", "download"] : ["token", "path", "sourcePath", "resourceDir"];
|
|
240
|
+
for (const key of parsed.searchParams.keys()) if (!allowed.includes(key) || parsed.searchParams.getAll(key).length !== 1) throw new Error("Unexpected read-only viewer parameter.");
|
|
241
|
+
if (!parsed.searchParams.get(prepared ? "id" : "path")) throw new Error("Missing read-only viewer identity.");
|
|
242
|
+
if (parsed.hash && (path === "/image-viewer" || !/^#page=[1-9][0-9]*$/.test(parsed.hash))) throw new Error("Invalid PDF/image fragment.");
|
|
243
|
+
return parsed.pathname + parsed.search + parsed.hash;
|
|
244
|
+
}
|
|
245
|
+
|
|
227
246
|
function openStudioTabDirect(windowLike, targetUrl) {
|
|
228
247
|
if (!windowLike || typeof windowLike.open !== "function") {
|
|
229
248
|
throw new Error("Opening browser tabs is unavailable.");
|
|
@@ -406,6 +425,10 @@
|
|
|
406
425
|
const normalizedTarget = normalizeStudioRelativeTarget(target, windowLike.location, token);
|
|
407
426
|
return setTerminal("navigate", { target: normalizedTarget });
|
|
408
427
|
},
|
|
428
|
+
navigateReadOnly(target) {
|
|
429
|
+
if (kind === "document") throw new Error("An editable Document launch cannot become a read-only viewer.");
|
|
430
|
+
return setTerminal("navigate-read-only", { target: normalizeStudioReadOnlyTarget(target, windowLike.location, token) });
|
|
431
|
+
},
|
|
409
432
|
fail(message) {
|
|
410
433
|
return setTerminal("error", { message: normalizeStudioLaunchMessage(message) });
|
|
411
434
|
},
|
|
@@ -514,19 +537,21 @@
|
|
|
514
537
|
const message = event && event.data;
|
|
515
538
|
if (!message || typeof message !== "object" || terminalHandled) return;
|
|
516
539
|
if (message.protocol !== STUDIO_LAUNCH_PROTOCOL_VERSION || message.launchId !== launchId) return;
|
|
517
|
-
if (message.type === "navigate") {
|
|
540
|
+
if (message.type === "navigate" || message.type === "navigate-read-only") {
|
|
518
541
|
terminalHandled = true;
|
|
519
542
|
let relativeTarget = "";
|
|
543
|
+
const readOnly = message.type === "navigate-read-only";
|
|
520
544
|
try {
|
|
521
|
-
|
|
545
|
+
if (readOnly && kind === "document") throw new Error("A Document launch is not a read-only viewer.");
|
|
546
|
+
relativeTarget = (readOnly ? normalizeStudioReadOnlyTarget : normalizeStudioRelativeTarget)(message.target, windowLike.location, token);
|
|
522
547
|
} catch {
|
|
523
|
-
acknowledge(
|
|
548
|
+
acknowledge(message.type, false);
|
|
524
549
|
render("Could not open Studio tab", "Studio rejected an invalid or unsafe navigation target.", true);
|
|
525
550
|
setTimer(cleanup, 50);
|
|
526
551
|
return;
|
|
527
552
|
}
|
|
528
|
-
acknowledge(
|
|
529
|
-
render("Opening Studio tab…", "The requested Studio view is ready.", false);
|
|
553
|
+
acknowledge(message.type, true);
|
|
554
|
+
render(readOnly ? "Opening read-only viewer…" : "Opening Studio tab…", readOnly ? "The requested PDF/image is ready." : "The requested Studio view is ready.", false);
|
|
530
555
|
setTimer(() => {
|
|
531
556
|
cleanup();
|
|
532
557
|
try {
|
|
@@ -611,6 +636,7 @@
|
|
|
611
636
|
normalizeStudioLaunchMessage,
|
|
612
637
|
normalizeStudioPendingKind,
|
|
613
638
|
normalizeStudioRelativeTarget,
|
|
639
|
+
normalizeStudioReadOnlyTarget,
|
|
614
640
|
openStudioTabDirect,
|
|
615
641
|
persistStudioWorkspaceState,
|
|
616
642
|
readPaneFocusTarget,
|