pi-studio 0.9.55 → 0.9.57
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/CHANGELOG.md +29 -0
- package/README.md +28 -2
- package/ROADMAP.md +31 -1
- package/client/studio-client.js +228 -50
- package/index.ts +914 -220
- package/package.json +1 -1
- package/shared/REPL_SESSION_RECORD_PROTOCOL.md +93 -0
- package/shared/repl-control-files.js +158 -0
- package/shared/repl-session-record.js +623 -0
- package/shared/repl-submission-display.js +227 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export const REPL_SUBMISSION_DISPLAY_VERSION = 1;
|
|
4
|
+
export const DEFAULT_REPL_SUBMISSION_ECHO_MODE = "off";
|
|
5
|
+
export const REPL_SUBMISSION_ECHO_MODES = Object.freeze(["off", "summary", "full"]);
|
|
6
|
+
export const REPL_SUBMISSION_SUMMARY_MAX_CHARS = 600;
|
|
7
|
+
export const REPL_SUBMISSION_SUMMARY_MAX_LINES = 6;
|
|
8
|
+
export const REPL_SUBMISSION_FULL_MAX_CHARS = 4_000;
|
|
9
|
+
export const REPL_SUBMISSION_FULL_MAX_LINES = 40;
|
|
10
|
+
|
|
11
|
+
const UNSAFE_UNICODE_PATTERN = /[\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
|
|
12
|
+
const OTHER_CONTROL_PATTERN = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g;
|
|
13
|
+
const COMPACT_BEGIN_MARKER_PATTERN = /^── ([a-z0-9][a-z0-9-]{0,31}) · ([a-f0-9]{12}) · ([1-9]\d*) (line|lines) ──$/;
|
|
14
|
+
const COMPACT_OUTPUT_MARKER = "── output ──";
|
|
15
|
+
const COMPACT_END_MARKER_PATTERN = /^── done · ([a-f0-9]{12}) ──$/;
|
|
16
|
+
const LEGACY_MARKER_PATTERN = /^── ([a-z0-9][a-z0-9-]{0,31}) (submitted|output|complete)(?: · ([1-9]\d*) (line|lines))? · ([a-f0-9]{12}) ──$/;
|
|
17
|
+
|
|
18
|
+
export function normalizeReplSubmissionEchoMode(value, fallback = DEFAULT_REPL_SUBMISSION_ECHO_MODE) {
|
|
19
|
+
const normalizedFallback = REPL_SUBMISSION_ECHO_MODES.includes(String(fallback || "").trim().toLowerCase())
|
|
20
|
+
? String(fallback).trim().toLowerCase()
|
|
21
|
+
: DEFAULT_REPL_SUBMISSION_ECHO_MODE;
|
|
22
|
+
const normalized = String(value || "").trim().toLowerCase();
|
|
23
|
+
return REPL_SUBMISSION_ECHO_MODES.includes(normalized) ? normalized : normalizedFallback;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function escapeCodePoint(char) {
|
|
27
|
+
const codePoint = char.codePointAt(0) ?? 0;
|
|
28
|
+
return codePoint <= 0xff
|
|
29
|
+
? `\\x${codePoint.toString(16).padStart(2, "0")}`
|
|
30
|
+
: `\\u{${codePoint.toString(16)}}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function sanitizeReplSubmissionDisplayText(value) {
|
|
34
|
+
return String(value || "")
|
|
35
|
+
.replace(/\r\n?/g, "\n")
|
|
36
|
+
.replace(/\t/g, " ")
|
|
37
|
+
.replace(OTHER_CONTROL_PATTERN, escapeCodePoint)
|
|
38
|
+
.replace(UNSAFE_UNICODE_PATTERN, escapeCodePoint);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function truncateCodePoints(value, maxChars) {
|
|
42
|
+
const chars = Array.from(String(value || ""));
|
|
43
|
+
if (chars.length <= maxChars) return { text: chars.join(""), truncated: false };
|
|
44
|
+
return { text: `${chars.slice(0, Math.max(0, maxChars - 1)).join("")}…`, truncated: true };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizeDisplayCode(code) {
|
|
48
|
+
const sanitized = sanitizeReplSubmissionDisplayText(code)
|
|
49
|
+
.split("\n")
|
|
50
|
+
.map((line) => line.replace(/ +$/g, ""))
|
|
51
|
+
.join("\n")
|
|
52
|
+
.replace(/\n+$/, "");
|
|
53
|
+
return sanitized || "(empty submission)";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function formatPreviewLine(line) {
|
|
57
|
+
return line ? `│ ${line}` : "│";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildBoundedPreviewLines(codeLines, maxLines, maxChars) {
|
|
61
|
+
const shown = [];
|
|
62
|
+
let usedChars = 0;
|
|
63
|
+
let truncated = false;
|
|
64
|
+
for (let index = 0; index < codeLines.length; index += 1) {
|
|
65
|
+
if (shown.length >= maxLines) {
|
|
66
|
+
truncated = true;
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
const line = codeLines[index];
|
|
70
|
+
const remaining = maxChars - usedChars;
|
|
71
|
+
if (remaining <= 0) {
|
|
72
|
+
truncated = true;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
const clipped = truncateCodePoints(line, remaining);
|
|
76
|
+
shown.push(formatPreviewLine(clipped.text));
|
|
77
|
+
usedChars += Array.from(clipped.text).length + 1;
|
|
78
|
+
if (clipped.truncated) {
|
|
79
|
+
truncated = true;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (shown.length < codeLines.length) truncated = true;
|
|
84
|
+
if (truncated) shown.push(`│ … preview truncated; ${codeLines.length} ${codeLines.length === 1 ? "line" : "lines"} total`);
|
|
85
|
+
return shown;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function createReplSubmissionDisplay(details = {}) {
|
|
89
|
+
const mode = normalizeReplSubmissionEchoMode(details.mode);
|
|
90
|
+
const origin = String(details.origin || "pi")
|
|
91
|
+
.trim()
|
|
92
|
+
.toLowerCase()
|
|
93
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
94
|
+
.replace(/^-+|-+$/g, "")
|
|
95
|
+
.slice(0, 32) || "pi";
|
|
96
|
+
const entryId = String(details.entryId || "");
|
|
97
|
+
const anchorId = createHash("sha256")
|
|
98
|
+
.update(`pi-repl-submission-display-v${REPL_SUBMISSION_DISPLAY_VERSION}\0${entryId}`, "utf8")
|
|
99
|
+
.digest("hex")
|
|
100
|
+
.slice(0, 12);
|
|
101
|
+
const displayCode = normalizeDisplayCode(details.code);
|
|
102
|
+
const codeLines = displayCode.split("\n");
|
|
103
|
+
const lineLabel = `${codeLines.length} ${codeLines.length === 1 ? "line" : "lines"}`;
|
|
104
|
+
const beginMarker = `── ${origin} · ${anchorId} · ${lineLabel} ──`;
|
|
105
|
+
const outputMarker = COMPACT_OUTPUT_MARKER;
|
|
106
|
+
const endMarker = `── done · ${anchorId} ──`;
|
|
107
|
+
const enabled = mode !== "off";
|
|
108
|
+
const previewLines = !enabled
|
|
109
|
+
? []
|
|
110
|
+
: mode === "full"
|
|
111
|
+
? buildBoundedPreviewLines(codeLines, REPL_SUBMISSION_FULL_MAX_LINES, REPL_SUBMISSION_FULL_MAX_CHARS)
|
|
112
|
+
: buildBoundedPreviewLines(codeLines, REPL_SUBMISSION_SUMMARY_MAX_LINES, REPL_SUBMISSION_SUMMARY_MAX_CHARS);
|
|
113
|
+
return {
|
|
114
|
+
version: REPL_SUBMISSION_DISPLAY_VERSION,
|
|
115
|
+
mode,
|
|
116
|
+
enabled,
|
|
117
|
+
origin,
|
|
118
|
+
entryId,
|
|
119
|
+
anchorId,
|
|
120
|
+
beginMarker,
|
|
121
|
+
outputMarker,
|
|
122
|
+
endMarker,
|
|
123
|
+
previewLines,
|
|
124
|
+
prefixLines: enabled ? [beginMarker, ...previewLines, outputMarker] : [],
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parseLineCount(countText, countLabel) {
|
|
129
|
+
const lineCount = Number(countText);
|
|
130
|
+
if (!Number.isSafeInteger(lineCount) || lineCount < 1) return null;
|
|
131
|
+
if ((lineCount === 1) !== (countLabel === "line")) return null;
|
|
132
|
+
return lineCount;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function parseReplSubmissionDisplayMarker(line) {
|
|
136
|
+
const normalized = String(line || "").replace(/\r$/, "");
|
|
137
|
+
const compactBegin = normalized.match(COMPACT_BEGIN_MARKER_PATTERN);
|
|
138
|
+
if (compactBegin) {
|
|
139
|
+
const [, origin, anchorId, countText, countLabel] = compactBegin;
|
|
140
|
+
const lineCount = parseLineCount(countText, countLabel);
|
|
141
|
+
return lineCount === null
|
|
142
|
+
? null
|
|
143
|
+
: { version: REPL_SUBMISSION_DISPLAY_VERSION, origin, phase: "submitted", anchorId, lineCount };
|
|
144
|
+
}
|
|
145
|
+
if (normalized === COMPACT_OUTPUT_MARKER) {
|
|
146
|
+
return { version: REPL_SUBMISSION_DISPLAY_VERSION, phase: "output" };
|
|
147
|
+
}
|
|
148
|
+
const compactEnd = normalized.match(COMPACT_END_MARKER_PATTERN);
|
|
149
|
+
if (compactEnd) {
|
|
150
|
+
return { version: REPL_SUBMISSION_DISPLAY_VERSION, phase: "complete", anchorId: compactEnd[1] };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Accept the earlier three-marker development format so raw histories made
|
|
154
|
+
// while testing it remain interpretable; new displays do not emit this form.
|
|
155
|
+
const legacy = normalized.match(LEGACY_MARKER_PATTERN);
|
|
156
|
+
if (!legacy) return null;
|
|
157
|
+
const [, origin, phase, countText, countLabel, anchorId] = legacy;
|
|
158
|
+
if (phase === "submitted") {
|
|
159
|
+
if (!countText) return null;
|
|
160
|
+
const lineCount = parseLineCount(countText, countLabel);
|
|
161
|
+
return lineCount === null
|
|
162
|
+
? null
|
|
163
|
+
: { version: REPL_SUBMISSION_DISPLAY_VERSION, origin, phase, anchorId, lineCount, legacy: true };
|
|
164
|
+
}
|
|
165
|
+
if (countText) return null;
|
|
166
|
+
return { version: REPL_SUBMISSION_DISPLAY_VERSION, origin, phase, anchorId, legacy: true };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function consumeExactDisplayLine(value, offset, line) {
|
|
170
|
+
if (!value.startsWith(line, offset)) return null;
|
|
171
|
+
let end = offset + line.length;
|
|
172
|
+
while (end < value.length && (value[end] === " " || value[end] === "\t")) end += 1;
|
|
173
|
+
if (end < value.length && value[end] !== "\n") return null;
|
|
174
|
+
return value[end] === "\n" ? end + 1 : end;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function findExactDisplayLine(value, line, useLast = false) {
|
|
178
|
+
let index = useLast ? value.lastIndexOf(line) : value.indexOf(line);
|
|
179
|
+
while (index >= 0) {
|
|
180
|
+
const startsLine = index === 0 || value[index - 1] === "\n";
|
|
181
|
+
const end = consumeExactDisplayLine(value, index, line);
|
|
182
|
+
if (startsLine && end !== null) return { index, end };
|
|
183
|
+
if (useLast) {
|
|
184
|
+
if (index === 0) break;
|
|
185
|
+
index = value.lastIndexOf(line, index - 1);
|
|
186
|
+
} else {
|
|
187
|
+
index = value.indexOf(line, index + line.length);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function removeMarkerLine(value, marker, useLast = false) {
|
|
194
|
+
const found = findExactDisplayLine(value, marker, useLast);
|
|
195
|
+
return found ? value.slice(0, found.index) + value.slice(found.end) : value;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function stripReplSubmissionDisplay(output, display) {
|
|
199
|
+
let value = String(output || "").replace(/\r\n?/g, "\n");
|
|
200
|
+
if (!display || display.enabled !== true) return value;
|
|
201
|
+
const begin = findExactDisplayLine(value, display.beginMarker);
|
|
202
|
+
if (begin) {
|
|
203
|
+
const beginIndex = begin.index;
|
|
204
|
+
const afterBegin = value.slice(begin.end);
|
|
205
|
+
const outputDivider = display.outputMarker
|
|
206
|
+
? findExactDisplayLine(afterBegin, display.outputMarker)
|
|
207
|
+
: null;
|
|
208
|
+
if (outputDivider) {
|
|
209
|
+
// The plain divider is an unambiguous boundary because it is emitted
|
|
210
|
+
// before user code runs. Removing through it is resilient to terminal
|
|
211
|
+
// wrapping or whitespace changes within the displayed source preview.
|
|
212
|
+
const suffixStart = begin.end + outputDivider.end;
|
|
213
|
+
value = value.slice(0, beginIndex) + value.slice(suffixStart);
|
|
214
|
+
} else {
|
|
215
|
+
// If the runtime disappears mid-prefix, remove only contiguous exact
|
|
216
|
+
// request lines and preserve any different error text after them.
|
|
217
|
+
let suffixStart = beginIndex;
|
|
218
|
+
for (const line of display.prefixLines) {
|
|
219
|
+
const next = consumeExactDisplayLine(value, suffixStart, line);
|
|
220
|
+
if (next === null) break;
|
|
221
|
+
suffixStart = next;
|
|
222
|
+
}
|
|
223
|
+
value = value.slice(0, beginIndex) + value.slice(suffixStart);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return removeMarkerLine(value, display.endMarker, true);
|
|
227
|
+
}
|