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.
Files changed (53) hide show
  1. package/BUFFER-DESIGN.md +197 -0
  2. package/CHANGELOG.md +31 -0
  3. package/PRERELEASE-CHECKLIST.md +100 -0
  4. package/README.md +110 -193
  5. package/ROADMAP.md +63 -6
  6. package/WORKFLOW.md +32 -2
  7. package/assets/screenshots/dark-workspace.png +0 -0
  8. package/assets/screenshots/light-workspace.png +0 -0
  9. package/assets/screenshots/theme-zen-code.png +0 -0
  10. package/client/studio-client.js +6694 -942
  11. package/client/studio-editor-draft-helpers.js +162 -0
  12. package/client/studio-navigation-helpers.js +31 -5
  13. package/client/studio.css +460 -12
  14. package/index.ts +1178 -79
  15. package/package.json +5 -3
  16. package/shared/STUDIO_BUFFER_FOUNDATION.md +164 -0
  17. package/shared/studio-buffer-client.js +663 -0
  18. package/shared/studio-buffer-decisions.js +129 -0
  19. package/shared/studio-buffer-lineage.js +48 -0
  20. package/shared/studio-buffer-recovery-panel.js +132 -0
  21. package/shared/studio-buffer-recovery.js +181 -0
  22. package/shared/studio-buffer-server.js +700 -0
  23. package/shared/studio-buffer-store.js +227 -0
  24. package/shared/studio-buffer-switching.js +53 -0
  25. package/shared/studio-disk-revisions.js +15 -0
  26. package/shared/studio-document-entrypoints.js +38 -0
  27. package/shared/studio-document-escrow.js +147 -0
  28. package/shared/studio-document-file-claims.js +215 -0
  29. package/shared/studio-document-host-client.js +268 -0
  30. package/shared/studio-document-hosting.js +319 -0
  31. package/shared/studio-document-launch-client.js +153 -0
  32. package/shared/studio-document-open-client.js +96 -0
  33. package/shared/studio-document-open-storage.js +21 -0
  34. package/shared/studio-document-paths.js +29 -0
  35. package/shared/studio-editor-mode.js +48 -0
  36. package/shared/studio-linked-reader-ui.js +232 -0
  37. package/shared/studio-linked-reader.js +110 -0
  38. package/shared/studio-metadata-write-order.js +35 -0
  39. package/shared/studio-read-only-media.js +24 -0
  40. package/shared/studio-resource-grants.js +3 -0
  41. package/shared/studio-run-lifecycle.js +47 -0
  42. package/shared/studio-system-theme.js +9 -0
  43. package/assets/screenshots/dark-code-mode.png +0 -0
  44. package/assets/screenshots/dark-critique.png +0 -0
  45. package/assets/screenshots/dark-fenced-code.png +0 -0
  46. package/assets/screenshots/dark-focus-mode.png +0 -0
  47. package/assets/screenshots/dark-julia-fenced.png +0 -0
  48. package/assets/screenshots/dark-math.png +0 -0
  49. package/assets/screenshots/dark-mermaid.png +0 -0
  50. package/assets/screenshots/dark-zen.png +0 -0
  51. package/assets/screenshots/light-code-mode.png +0 -0
  52. package/assets/screenshots/light-fenced-code.png +0 -0
  53. 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
- relativeTarget = normalizeStudioRelativeTarget(message.target, windowLike.location, token);
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("navigate", false);
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("navigate", true);
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,