myjsbook 1.0.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.
Files changed (242) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +263 -0
  3. package/package.json +38 -0
  4. package/public/app.js +6686 -0
  5. package/public/components/constants.js +421 -0
  6. package/public/components/elements.js +118 -0
  7. package/public/components/state.js +53 -0
  8. package/public/icons/audio.svg +1 -0
  9. package/public/icons/azure.svg +1 -0
  10. package/public/icons/babel.svg +1 -0
  11. package/public/icons/bun.svg +1 -0
  12. package/public/icons/bun_light.svg +1 -0
  13. package/public/icons/c.svg +1 -0
  14. package/public/icons/chrome.svg +1 -0
  15. package/public/icons/citation.svg +1 -0
  16. package/public/icons/claude.svg +1 -0
  17. package/public/icons/console.svg +1 -0
  18. package/public/icons/cpp.svg +1 -0
  19. package/public/icons/css-map.svg +1 -0
  20. package/public/icons/css.svg +1 -0
  21. package/public/icons/database.svg +1 -0
  22. package/public/icons/docker.svg +1 -0
  23. package/public/icons/document.svg +1 -0
  24. package/public/icons/ejs.svg +1 -0
  25. package/public/icons/exe.svg +1 -0
  26. package/public/icons/favicon.svg +1 -0
  27. package/public/icons/figma.svg +1 -0
  28. package/public/icons/firebase.svg +1 -0
  29. package/public/icons/folder-admin-open.svg +1 -0
  30. package/public/icons/folder-admin.svg +1 -0
  31. package/public/icons/folder-api-open.svg +1 -0
  32. package/public/icons/folder-api.svg +1 -0
  33. package/public/icons/folder-app-open.svg +1 -0
  34. package/public/icons/folder-app.svg +1 -0
  35. package/public/icons/folder-archive-open.svg +1 -0
  36. package/public/icons/folder-archive.svg +1 -0
  37. package/public/icons/folder-attachment-open.svg +1 -0
  38. package/public/icons/folder-attachment.svg +1 -0
  39. package/public/icons/folder-aws-open.svg +1 -0
  40. package/public/icons/folder-aws.svg +1 -0
  41. package/public/icons/folder-backup-open.svg +1 -0
  42. package/public/icons/folder-backup.svg +1 -0
  43. package/public/icons/folder-class-open.svg +1 -0
  44. package/public/icons/folder-class.svg +1 -0
  45. package/public/icons/folder-claude-open.svg +1 -0
  46. package/public/icons/folder-claude.svg +1 -0
  47. package/public/icons/folder-client-open.svg +1 -0
  48. package/public/icons/folder-client.svg +1 -0
  49. package/public/icons/folder-command-open.svg +1 -0
  50. package/public/icons/folder-command.svg +1 -0
  51. package/public/icons/folder-components-open.svg +1 -0
  52. package/public/icons/folder-components.svg +1 -0
  53. package/public/icons/folder-config-open.svg +1 -0
  54. package/public/icons/folder-config.svg +1 -0
  55. package/public/icons/folder-connection-open.svg +1 -0
  56. package/public/icons/folder-connection.svg +1 -0
  57. package/public/icons/folder-console-open.svg +1 -0
  58. package/public/icons/folder-console.svg +1 -0
  59. package/public/icons/folder-container-open.svg +1 -0
  60. package/public/icons/folder-container.svg +1 -0
  61. package/public/icons/folder-content-open.svg +1 -0
  62. package/public/icons/folder-content.svg +1 -0
  63. package/public/icons/folder-context-open.svg +1 -0
  64. package/public/icons/folder-context.svg +1 -0
  65. package/public/icons/folder-controller-open.svg +1 -0
  66. package/public/icons/folder-controller.svg +1 -0
  67. package/public/icons/folder-core-open.svg +1 -0
  68. package/public/icons/folder-core.svg +1 -0
  69. package/public/icons/folder-css-open.svg +1 -0
  70. package/public/icons/folder-css.svg +1 -0
  71. package/public/icons/folder-custom-open.svg +1 -0
  72. package/public/icons/folder-custom.svg +1 -0
  73. package/public/icons/folder-database-open.svg +1 -0
  74. package/public/icons/folder-database.svg +1 -0
  75. package/public/icons/folder-decorators-open.svg +1 -0
  76. package/public/icons/folder-decorators.svg +1 -0
  77. package/public/icons/folder-desktop-open.svg +1 -0
  78. package/public/icons/folder-desktop.svg +1 -0
  79. package/public/icons/folder-dist-open.svg +1 -0
  80. package/public/icons/folder-dist.svg +1 -0
  81. package/public/icons/folder-docs-open.svg +1 -0
  82. package/public/icons/folder-docs.svg +1 -0
  83. package/public/icons/folder-download-open.svg +1 -0
  84. package/public/icons/folder-download.svg +1 -0
  85. package/public/icons/folder-dtos-open.svg +1 -0
  86. package/public/icons/folder-dtos.svg +1 -0
  87. package/public/icons/folder-element-open.svg +1 -0
  88. package/public/icons/folder-element.svg +1 -0
  89. package/public/icons/folder-environment-open.svg +1 -0
  90. package/public/icons/folder-environment.svg +1 -0
  91. package/public/icons/folder-error-open.svg +1 -0
  92. package/public/icons/folder-error.svg +1 -0
  93. package/public/icons/folder-event-open.svg +1 -0
  94. package/public/icons/folder-event.svg +1 -0
  95. package/public/icons/folder-examples-open.svg +1 -0
  96. package/public/icons/folder-examples.svg +1 -0
  97. package/public/icons/folder-expo-open.svg +1 -0
  98. package/public/icons/folder-expo.svg +1 -0
  99. package/public/icons/folder-export-open.svg +1 -0
  100. package/public/icons/folder-export.svg +1 -0
  101. package/public/icons/folder-features-open.svg +1 -0
  102. package/public/icons/folder-features.svg +1 -0
  103. package/public/icons/folder-filter-open.svg +1 -0
  104. package/public/icons/folder-filter.svg +1 -0
  105. package/public/icons/folder-firebase-open.svg +1 -0
  106. package/public/icons/folder-firebase.svg +1 -0
  107. package/public/icons/folder-firestore-open.svg +1 -0
  108. package/public/icons/folder-firestore.svg +1 -0
  109. package/public/icons/folder-font-open.svg +1 -0
  110. package/public/icons/folder-font.svg +1 -0
  111. package/public/icons/folder-functions-open.svg +1 -0
  112. package/public/icons/folder-functions.svg +1 -0
  113. package/public/icons/folder-gemini-ai-open.svg +1 -0
  114. package/public/icons/folder-gemini-ai.svg +1 -0
  115. package/public/icons/folder-git-open.svg +1 -0
  116. package/public/icons/folder-git.svg +1 -0
  117. package/public/icons/folder-github-open.svg +1 -0
  118. package/public/icons/folder-github.svg +1 -0
  119. package/public/icons/folder-helper-open.svg +1 -0
  120. package/public/icons/folder-helper.svg +1 -0
  121. package/public/icons/folder-home-open.svg +1 -0
  122. package/public/icons/folder-home.svg +1 -0
  123. package/public/icons/folder-icons-open.svg +1 -0
  124. package/public/icons/folder-icons.svg +1 -0
  125. package/public/icons/folder-images-open.svg +1 -0
  126. package/public/icons/folder-images.svg +1 -0
  127. package/public/icons/folder-interface-open.svg +1 -0
  128. package/public/icons/folder-interface.svg +1 -0
  129. package/public/icons/folder-ios-open.svg +1 -0
  130. package/public/icons/folder-ios.svg +1 -0
  131. package/public/icons/folder-java-open.svg +1 -0
  132. package/public/icons/folder-java.svg +1 -0
  133. package/public/icons/folder-javascript-open.svg +1 -0
  134. package/public/icons/folder-javascript.svg +1 -0
  135. package/public/icons/folder-middleware-open.svg +1 -0
  136. package/public/icons/folder-middleware.svg +1 -0
  137. package/public/icons/folder-migrations-open.svg +1 -0
  138. package/public/icons/folder-migrations.svg +1 -0
  139. package/public/icons/folder-other-open.svg +1 -0
  140. package/public/icons/folder-other.svg +1 -0
  141. package/public/icons/folder-packages-open.svg +1 -0
  142. package/public/icons/folder-packages.svg +1 -0
  143. package/public/icons/folder-pdf-open.svg +1 -0
  144. package/public/icons/folder-pdf.svg +1 -0
  145. package/public/icons/folder-plugin-open.svg +1 -0
  146. package/public/icons/folder-plugin.svg +1 -0
  147. package/public/icons/folder-project-open.svg +1 -0
  148. package/public/icons/folder-project.svg +1 -0
  149. package/public/icons/folder-public-open.svg +1 -0
  150. package/public/icons/folder-public.svg +1 -0
  151. package/public/icons/folder-python-open.svg +1 -0
  152. package/public/icons/folder-python.svg +1 -0
  153. package/public/icons/folder-repository-open.svg +1 -0
  154. package/public/icons/folder-repository.svg +1 -0
  155. package/public/icons/folder-routes-open.svg +1 -0
  156. package/public/icons/folder-routes.svg +1 -0
  157. package/public/icons/folder-rules-open.svg +1 -0
  158. package/public/icons/folder-rules.svg +1 -0
  159. package/public/icons/folder-sass-open.svg +1 -0
  160. package/public/icons/folder-sass.svg +1 -0
  161. package/public/icons/folder-scripts-open.svg +1 -0
  162. package/public/icons/folder-scripts.svg +1 -0
  163. package/public/icons/folder-server-open.svg +1 -0
  164. package/public/icons/folder-server.svg +1 -0
  165. package/public/icons/folder-serverless-open.svg +1 -0
  166. package/public/icons/folder-serverless.svg +1 -0
  167. package/public/icons/folder-skills-open.svg +1 -0
  168. package/public/icons/folder-skills.svg +1 -0
  169. package/public/icons/folder-src-open.svg +1 -0
  170. package/public/icons/folder-src.svg +1 -0
  171. package/public/icons/folder-stack-open.svg +1 -0
  172. package/public/icons/folder-stack.svg +1 -0
  173. package/public/icons/folder-store-open.svg +1 -0
  174. package/public/icons/folder-store.svg +1 -0
  175. package/public/icons/folder-supabase-open.svg +1 -0
  176. package/public/icons/folder-supabase.svg +1 -0
  177. package/public/icons/folder-svg-open.svg +1 -0
  178. package/public/icons/folder-svg.svg +1 -0
  179. package/public/icons/folder-target-open.svg +1 -0
  180. package/public/icons/folder-target.svg +1 -0
  181. package/public/icons/folder-tasks-open.svg +1 -0
  182. package/public/icons/folder-tasks.svg +1 -0
  183. package/public/icons/folder-temp-open.svg +1 -0
  184. package/public/icons/folder-temp.svg +1 -0
  185. package/public/icons/folder-template-open.svg +1 -0
  186. package/public/icons/folder-template.svg +1 -0
  187. package/public/icons/folder-test-open.svg +1 -0
  188. package/public/icons/folder-test.svg +1 -0
  189. package/public/icons/folder-tools-open.svg +1 -0
  190. package/public/icons/folder-tools.svg +1 -0
  191. package/public/icons/folder-typescript-open.svg +1 -0
  192. package/public/icons/folder-typescript.svg +1 -0
  193. package/public/icons/folder-ui-open.svg +1 -0
  194. package/public/icons/folder-ui.svg +1 -0
  195. package/public/icons/folder-upload-open.svg +1 -0
  196. package/public/icons/folder-upload.svg +1 -0
  197. package/public/icons/folder-utils-open.svg +1 -0
  198. package/public/icons/folder-utils.svg +1 -0
  199. package/public/icons/folder-video-open.svg +1 -0
  200. package/public/icons/folder-video.svg +1 -0
  201. package/public/icons/folder-views-open.svg +1 -0
  202. package/public/icons/folder-views.svg +1 -0
  203. package/public/icons/font.svg +1 -0
  204. package/public/icons/gemini-ai.svg +1 -0
  205. package/public/icons/gemini.svg +1 -0
  206. package/public/icons/git.svg +1 -0
  207. package/public/icons/google.svg +1 -0
  208. package/public/icons/graphql.svg +1 -0
  209. package/public/icons/html.svg +1 -0
  210. package/public/icons/image.svg +1 -0
  211. package/public/icons/java.svg +1 -0
  212. package/public/icons/javaclass.svg +1 -0
  213. package/public/icons/javascript.svg +1 -0
  214. package/public/icons/jsconfig.svg +1 -0
  215. package/public/icons/json.svg +1 -0
  216. package/public/icons/markdown.svg +1 -0
  217. package/public/icons/nodejs.svg +1 -0
  218. package/public/icons/nodejs_alt.svg +1 -0
  219. package/public/icons/nodemon.svg +1 -0
  220. package/public/icons/npm.svg +1 -0
  221. package/public/icons/pdf.svg +1 -0
  222. package/public/icons/prettier.svg +1 -0
  223. package/public/icons/prisma.svg +1 -0
  224. package/public/icons/python.svg +1 -0
  225. package/public/icons/react.svg +1 -0
  226. package/public/icons/react_ts.svg +1 -0
  227. package/public/icons/readme.svg +1 -0
  228. package/public/icons/remark.svg +1 -0
  229. package/public/icons/sass.svg +1 -0
  230. package/public/icons/svg.svg +1 -0
  231. package/public/icons/tailwindcss.svg +1 -0
  232. package/public/icons/typescript-def.svg +1 -0
  233. package/public/icons/typescript.svg +1 -0
  234. package/public/icons/zip.svg +1 -0
  235. package/public/index.html +1342 -0
  236. package/public/styles.css +4736 -0
  237. package/src/cli.js +175 -0
  238. package/src/lib/files.js +143 -0
  239. package/src/lib/notebook.js +141 -0
  240. package/src/lib/package-exports.js +331 -0
  241. package/src/lib/session.js +1003 -0
  242. package/src/server.js +2232 -0
package/src/server.js ADDED
@@ -0,0 +1,2232 @@
1
+ import fs from "node:fs";
2
+ import http from "node:http";
3
+ import path from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
+ import { createRequire } from "node:module";
7
+ import { randomUUID } from "node:crypto";
8
+ import prettier from "prettier";
9
+ import * as prettierPluginBabel from "prettier/plugins/babel";
10
+ import * as prettierPluginEstree from "prettier/plugins/estree";
11
+ import * as prettierPluginMarkdown from "prettier/plugins/markdown";
12
+ import * as prettierPluginTypeScript from "prettier/plugins/typescript";
13
+
14
+ import {
15
+ deriveNotebookPathFromTitle,
16
+ getAppPathFromWorkspacePath,
17
+ getOpenableFileKind,
18
+ isOpenableFile,
19
+ resolveWorkspaceOpenPath
20
+ } from "./lib/files.js";
21
+ import { createNotebook, normalizeNotebook, NOTEBOOK_EXTENSION } from "./lib/notebook.js";
22
+ import { collectDeclaredPackageExports } from "./lib/package-exports.js";
23
+ import { KernelSession } from "./lib/session.js";
24
+
25
+ const __filename = fileURLToPath(import.meta.url);
26
+ const __dirname = path.dirname(__filename);
27
+ const packageRoot = path.resolve(__dirname, "..");
28
+ const publicDir = path.join(packageRoot, "public");
29
+ const monacoDir = path.join(packageRoot, "node_modules", "monaco-editor", "min");
30
+
31
+ const MIME_TYPES = {
32
+ ".html": "text/html; charset=utf-8",
33
+ ".css": "text/css; charset=utf-8",
34
+ ".js": "application/javascript; charset=utf-8",
35
+ ".json": "application/json; charset=utf-8",
36
+ ".svg": "image/svg+xml; charset=utf-8",
37
+ ".txt": "text/plain; charset=utf-8",
38
+ ".md": "text/markdown; charset=utf-8",
39
+ ".ts": "text/plain; charset=utf-8",
40
+ ".png": "image/png",
41
+ ".jpg": "image/jpeg",
42
+ ".jpeg": "image/jpeg",
43
+ ".gif": "image/gif",
44
+ ".webp": "image/webp",
45
+ ".avif": "image/avif",
46
+ ".pdf": "application/pdf"
47
+ };
48
+
49
+ const GROQ_MODELS_FALLBACK = [
50
+ "llama-3.3-70b-versatile",
51
+ "llama-3.1-8b-instant",
52
+ "openai/gpt-oss-120b",
53
+ "openai/gpt-oss-20b"
54
+ ];
55
+
56
+ /**
57
+ * Fetch the live model list from Groq, filter to llama/openai models only,
58
+ * return at most 5. Falls back to GROQ_MODELS_FALLBACK if the call fails or
59
+ * no API key is available.
60
+ */
61
+ async function fetchGroqModels(apiKey) {
62
+ if (!apiKey) return GROQ_MODELS_FALLBACK;
63
+ try {
64
+ const res = await fetch("https://api.groq.com/openai/v1/models", {
65
+ headers: { Authorization: `Bearer ${apiKey}` }
66
+ });
67
+ if (!res.ok) return GROQ_MODELS_FALLBACK;
68
+ const json = await res.json();
69
+ const ids = (json.data ?? [])
70
+ .map((m) => m.id)
71
+ .filter((id) => typeof id === "string" && (id.startsWith("llama") || id.startsWith("openai/")))
72
+ .slice(0, 5);
73
+ return ids.length ? ids : GROQ_MODELS_FALLBACK;
74
+ } catch {
75
+ return GROQ_MODELS_FALLBACK;
76
+ }
77
+ }
78
+
79
+ function estimateTokens(text = "") {
80
+ return Math.max(0, Math.ceil(String(text ?? "").length / 4));
81
+ }
82
+
83
+ function json(response, statusCode, body, compact = false) {
84
+ response.writeHead(statusCode, {
85
+ "content-type": "application/json; charset=utf-8"
86
+ });
87
+ // Use compact JSON for large machine-to-machine payloads (e.g. type libraries)
88
+ response.end(compact ? JSON.stringify(body) : JSON.stringify(body, null, 2));
89
+ }
90
+
91
+ function notFound(response) {
92
+ json(response, 404, { error: "Not found" });
93
+ }
94
+
95
+ function resolveNotebookPath(workspaceRoot, notebooksDir, requestedPath) {
96
+ if (!requestedPath) {
97
+ // Default: create untitled.ijsnb directly in the workspace root
98
+ return path.join(workspaceRoot, `untitled${NOTEBOOK_EXTENSION}`);
99
+ }
100
+
101
+ // Strip the /notebooks/ URL prefix if the frontend passed a browser URL path.
102
+ // e.g. "/notebooks/my_notebook.ijsnb" → "my_notebook.ijsnb"
103
+ // "/notebooks/any_folder/basic.ijsnb" → "any_folder/basic.ijsnb"
104
+ let cleanPath = requestedPath;
105
+ if (!path.isAbsolute(cleanPath)) {
106
+ cleanPath = cleanPath.replace(/^\/+/, "");
107
+ if (cleanPath.startsWith("notebooks/")) {
108
+ cleanPath = cleanPath.slice("notebooks/".length);
109
+ }
110
+ }
111
+
112
+ const absolute = path.isAbsolute(cleanPath)
113
+ ? cleanPath
114
+ : path.resolve(workspaceRoot, cleanPath);
115
+
116
+ const withExtension = absolute.endsWith(NOTEBOOK_EXTENSION) ? absolute : `${absolute}${NOTEBOOK_EXTENSION}`;
117
+
118
+ // Security: ensure the resolved path is inside the workspace
119
+ if (!withExtension.startsWith(workspaceRoot)) {
120
+ return path.join(workspaceRoot, path.basename(withExtension));
121
+ }
122
+
123
+ return withExtension;
124
+ }
125
+
126
+ function resolveWorkspacePath(workspaceRoot, requestedPath) {
127
+ const absolute = requestedPath
128
+ ? path.isAbsolute(requestedPath)
129
+ ? requestedPath
130
+ : path.resolve(workspaceRoot, requestedPath)
131
+ : workspaceRoot;
132
+
133
+ if (!absolute.startsWith(workspaceRoot)) {
134
+ throw new Error("Path is outside the workspace");
135
+ }
136
+
137
+ return absolute;
138
+ }
139
+
140
+ // Folders that should never appear in the workspace file explorer.
141
+ const HIDDEN_DIRECTORY_NAMES = new Set([
142
+ "node_modules",
143
+ ".nodebook-cache",
144
+ ".git",
145
+ "env",
146
+ "dist",
147
+ "build",
148
+ ".next",
149
+ ".nuxt"
150
+ ]);
151
+
152
+ // Files that should never appear in the workspace file explorer.
153
+ const HIDDEN_FILE_NAMES = new Set([
154
+ "package-lock.json",
155
+ "yarn.lock",
156
+ "pnpm-lock.yaml"
157
+ ]);
158
+
159
+ async function listDirectoryEntries(workspaceRoot, directoryPath) {
160
+ const directoryEntries = await fs.promises.readdir(directoryPath, {
161
+ withFileTypes: true
162
+ });
163
+
164
+ return directoryEntries
165
+ .filter((entry) => {
166
+ if (entry.name.startsWith(".")) return false;
167
+ if (entry.isDirectory() && HIDDEN_DIRECTORY_NAMES.has(entry.name)) return false;
168
+ if (!entry.isDirectory() && HIDDEN_FILE_NAMES.has(entry.name)) return false;
169
+ return true;
170
+ })
171
+ .map((entry) => {
172
+ const absolutePath = path.join(directoryPath, entry.name);
173
+ const relativePath = path.relative(workspaceRoot, absolutePath) || ".";
174
+ const isDirectory = entry.isDirectory();
175
+
176
+ return {
177
+ name: entry.name,
178
+ path: absolutePath,
179
+ relativePath,
180
+ type: isDirectory ? "directory" : "file",
181
+ openable: !isDirectory && isOpenableFile(absolutePath),
182
+ fileKind: !isDirectory ? getOpenableFileKind(absolutePath) : null,
183
+ expandable: isDirectory
184
+ };
185
+ })
186
+ .sort((left, right) => {
187
+ if (left.type !== right.type) {
188
+ return left.type === "directory" ? -1 : 1;
189
+ }
190
+
191
+ return left.name.localeCompare(right.name);
192
+ });
193
+ }
194
+
195
+ async function readRequestBody(request) {
196
+ const chunks = [];
197
+
198
+ for await (const chunk of request) {
199
+ chunks.push(chunk);
200
+ }
201
+
202
+ if (chunks.length === 0) {
203
+ return {};
204
+ }
205
+
206
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
207
+ }
208
+
209
+ async function ensureNotebook(notebookPath) {
210
+ await fs.promises.mkdir(path.dirname(notebookPath), { recursive: true });
211
+
212
+ if (!fs.existsSync(notebookPath)) {
213
+ const notebook = createNotebook(path.basename(notebookPath, NOTEBOOK_EXTENSION));
214
+ await fs.promises.writeFile(notebookPath, JSON.stringify(notebook, null, 2));
215
+ return notebook;
216
+ }
217
+
218
+ const raw = await fs.promises.readFile(notebookPath, "utf8");
219
+
220
+ // Handle blank or malformed .ijsnb files created externally (e.g. `touch notebook.ijsnb`).
221
+ // Rather than crashing with "Unexpected end of JSON input", we initialise a fresh
222
+ // notebook with a single empty code cell and write it back so the file is valid.
223
+ let parsed;
224
+ try {
225
+ parsed = JSON.parse(raw);
226
+ } catch {
227
+ const notebook = createNotebook(path.basename(notebookPath, NOTEBOOK_EXTENSION));
228
+ await fs.promises.writeFile(notebookPath, JSON.stringify(notebook, null, 2));
229
+ return notebook;
230
+ }
231
+
232
+ return normalizeNotebook(parsed);
233
+ }
234
+
235
+ async function saveNotebook(notebookPath, notebook) {
236
+ const normalized = normalizeNotebook(notebook);
237
+ await fs.promises.mkdir(path.dirname(notebookPath), { recursive: true });
238
+ await fs.promises.writeFile(notebookPath, JSON.stringify(normalized, null, 2));
239
+ return normalized;
240
+ }
241
+
242
+ async function saveNotebookAtPath(currentPath, notebook, nextPath = currentPath) {
243
+ const normalized = normalizeNotebook(notebook);
244
+ const targetPath = nextPath || currentPath;
245
+ await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
246
+ await fs.promises.writeFile(targetPath, JSON.stringify(normalized, null, 2));
247
+
248
+ if (currentPath !== targetPath && fs.existsSync(currentPath)) {
249
+ await fs.promises.unlink(currentPath);
250
+ }
251
+
252
+ return {
253
+ notebook: normalized,
254
+ notebookPath: targetPath
255
+ };
256
+ }
257
+
258
+ function createNotebookRequire(notebookPath) {
259
+ return createRequire(path.join(path.dirname(notebookPath), "__nodebook__.cjs"));
260
+ }
261
+
262
+ /**
263
+ * Find the root directory of an @types/ package by walking up the directory tree.
264
+ *
265
+ * `@types/*` packages contain ONLY `.d.ts` declaration files — they have no
266
+ * JavaScript entry point. This means `require.resolve('@types/express')` always
267
+ * throws MODULE_NOT_FOUND, so we cannot use resolvePackageRoot() for them.
268
+ * Instead we look for the package directory directly in every node_modules/@types/
269
+ * folder on the path from the notebook directory up to the filesystem root.
270
+ *
271
+ * @param {string} notebookPath – absolute path to the .ijsnb file
272
+ * @param {string} atTypesName – full scoped name, e.g. "@types/express" or "@types/scope__pkg"
273
+ * @returns {string|null} absolute path to the @types/<name> root, or null if not found
274
+ */
275
+ function findAtTypesRoot(notebookPath, atTypesName) {
276
+ // Strip the "@types/" prefix to get the directory name inside @types/
277
+ const suffix = atTypesName.startsWith("@types/")
278
+ ? atTypesName.slice("@types/".length)
279
+ : atTypesName;
280
+
281
+ let dir = path.dirname(notebookPath);
282
+ const root = path.parse(dir).root;
283
+
284
+ while (dir !== root) {
285
+ const candidate = path.join(dir, "node_modules", "@types", suffix);
286
+ if (fs.existsSync(path.join(candidate, "package.json"))) {
287
+ return candidate;
288
+ }
289
+ dir = path.dirname(dir);
290
+ }
291
+
292
+ return null;
293
+ }
294
+
295
+ function findPackageRootFromEntry(entryPath) {
296
+ let currentPath = fs.statSync(entryPath).isDirectory() ? entryPath : path.dirname(entryPath);
297
+
298
+ while (currentPath !== path.dirname(currentPath)) {
299
+ if (fs.existsSync(path.join(currentPath, "package.json"))) {
300
+ return currentPath;
301
+ }
302
+
303
+ currentPath = path.dirname(currentPath);
304
+ }
305
+
306
+ return null;
307
+ }
308
+
309
+ function resolvePackageRoot(notebookPath, moduleName) {
310
+ try {
311
+ const notebookRequire = createNotebookRequire(notebookPath);
312
+ const entryPath = notebookRequire.resolve(moduleName);
313
+ return findPackageRootFromEntry(entryPath);
314
+ } catch {
315
+ return null;
316
+ }
317
+ }
318
+
319
+ function getNotebookNodeModulesRoot(notebookPath) {
320
+ return path.join(path.dirname(notebookPath), "node_modules");
321
+ }
322
+
323
+ function toVirtualModuleFile(notebookPath, absolutePath) {
324
+ const nodeModulesRoot = getNotebookNodeModulesRoot(notebookPath);
325
+
326
+ if (absolutePath.startsWith(nodeModulesRoot)) {
327
+ const relativePart = absolutePath.slice(nodeModulesRoot.length);
328
+ return `file:///node_modules${relativePart.replaceAll(path.sep, "/")}`;
329
+ }
330
+
331
+ return pathToFileURL(absolutePath).href;
332
+ }
333
+
334
+ function getDeclarationImportSpecifier(declarationPath, packageRoot) {
335
+ const relativePath = path.relative(packageRoot, declarationPath).replaceAll(path.sep, "/");
336
+
337
+ return `./${relativePath.replace(/\.d\.(cts|mts|ts)$/, "")}`;
338
+ }
339
+
340
+ function resolveDeclarationEntry(packageRoot, packageJson) {
341
+ const candidates = [];
342
+ const rootExport = packageJson.exports?.["."];
343
+
344
+ if (typeof rootExport === "object" && rootExport !== null) {
345
+ if (typeof rootExport.types === "string") {
346
+ candidates.push(rootExport.types);
347
+ }
348
+
349
+ if (typeof rootExport.import === "object" && rootExport.import !== null && typeof rootExport.import.types === "string") {
350
+ candidates.push(rootExport.import.types);
351
+ }
352
+
353
+ if (typeof rootExport.require === "object" && rootExport.require !== null && typeof rootExport.require.types === "string") {
354
+ candidates.push(rootExport.require.types);
355
+ }
356
+ }
357
+
358
+ if (typeof packageJson.types === "string") {
359
+ candidates.push(packageJson.types);
360
+ }
361
+
362
+ if (typeof packageJson.typings === "string") {
363
+ candidates.push(packageJson.typings);
364
+ }
365
+
366
+ if (typeof packageJson.module === "string") {
367
+ candidates.push(packageJson.module.replace(/\.js$/, ".d.ts"));
368
+ }
369
+
370
+ if (typeof packageJson.main === "string") {
371
+ candidates.push(packageJson.main.replace(/\.cjs$/, ".d.cts").replace(/\.js$/, ".d.ts"));
372
+ }
373
+
374
+ candidates.push("index.d.ts", "index.d.cts", "index.d.mts");
375
+
376
+ for (const candidate of candidates) {
377
+ const absolutePath = path.resolve(packageRoot, candidate);
378
+
379
+ if (fs.existsSync(absolutePath)) {
380
+ return absolutePath;
381
+ }
382
+ }
383
+
384
+ return null;
385
+ }
386
+
387
+ async function collectDeclarationFiles(packageRoot, maxFiles = 660) {
388
+ const results = [];
389
+ const queue = [packageRoot];
390
+
391
+ while (queue.length > 0 && results.length < maxFiles) {
392
+ const currentDirectory = queue.shift();
393
+ const directoryEntries = await fs.promises.readdir(currentDirectory, {
394
+ withFileTypes: true
395
+ });
396
+
397
+ for (const entry of directoryEntries) {
398
+ if (results.length >= maxFiles) {
399
+ break;
400
+ }
401
+
402
+ if (entry.name === "node_modules") {
403
+ continue;
404
+ }
405
+
406
+ const absolutePath = path.join(currentDirectory, entry.name);
407
+
408
+ if (entry.isDirectory()) {
409
+ queue.push(absolutePath);
410
+ continue;
411
+ }
412
+
413
+ if (entry.isFile() && (entry.name.endsWith(".d.ts") || entry.name.endsWith(".d.cts") || entry.name.endsWith(".d.mts"))) {
414
+ results.push(absolutePath);
415
+ }
416
+ }
417
+ }
418
+
419
+ return results;
420
+ }
421
+
422
+ async function discoverAtTypesPackages(notebookPath) {
423
+ const nodeModulesRoot = getNotebookNodeModulesRoot(notebookPath);
424
+ const atTypesDir = path.join(nodeModulesRoot, "@types");
425
+ const results = [];
426
+
427
+ if (!fs.existsSync(atTypesDir)) {
428
+ return results;
429
+ }
430
+
431
+ const entries = await fs.promises.readdir(atTypesDir, { withFileTypes: true });
432
+
433
+ for (const entry of entries) {
434
+ if (!entry.isDirectory()) {
435
+ continue;
436
+ }
437
+
438
+ const scopedName = `@types/${entry.name}`;
439
+ const packageRoot = path.join(atTypesDir, entry.name);
440
+ results.push({ moduleName: entry.name, scopedName, packageRoot });
441
+ }
442
+
443
+ return results;
444
+ }
445
+
446
+ /**
447
+ * Rewrite `.js` / `.cjs` / `.mjs` extension imports inside a `.d.ts` file to their
448
+ * declaration-file equivalents so TypeScript can resolve them inside the virtual FS.
449
+ * e.g. `from "./chat_models.js"` → `from "./chat_models"`
450
+ */
451
+ function rewriteDeclarationImports(content) {
452
+ return content.replace(
453
+ /(from\s+["'])(\.{1,2}\/[^"']*?)\.(js|cjs|mjs)(["'])/g,
454
+ "$1$2$4"
455
+ );
456
+ }
457
+
458
+ // Maximum bytes for a single declaration file. Giant auto-generated rollup
459
+ // files (e.g. @google/genai ships three ~480 KB .d.ts files that are identical
460
+ // copies of the full API) provide no extra IntelliSense value and burn through
461
+ // the shared size budget before smaller, more important packages are loaded.
462
+ const MAX_BYTES_PER_FILE = 1000_000; // 1 MB per file
463
+
464
+ // Total byte budget for all declaration content. 10 MB is enough for the
465
+ // packages a typical notebook actually imports; the client now requests only
466
+ // used packages so the budget is rarely approached.
467
+ const MAX_TOTAL_BYTES = 10_000_000; // 10 MB total
468
+
469
+ // ─── Per-package type-library cache ────────────────────────────────────────
470
+ // Keyed by `${packageRoot}:${mtimeMs}` of the package's package.json.
471
+ // Value: Array of { seenKey, file, moduleName, content, contentBytes }
472
+ // seenKey – the key put into `seenFiles` (abs path for .d.ts, virtual path for pkg.json)
473
+ // file – virtual monaco path ("file:///node_modules/…")
474
+ // The cache is per-packageRoot so adding/removing one package only invalidates
475
+ // that package's entry; all others remain warm.
476
+ const packageTypeLibsCache = new Map();
477
+
478
+ // ─── Per-package exports cache ──────────────────────────────────────────────
479
+ // Keyed by `${packageRoot}:${mtimeMs}`. Value: ExportEntry[]
480
+ const packageExportsCache = new Map();
481
+
482
+ async function addPackageTypeLibraries(notebookPath, moduleName, packageRoot, typeLibraries, seenFiles, stats) {
483
+ const pkgJsonPath = path.join(packageRoot, "package.json");
484
+
485
+ // ── Cache lookup ──────────────────────────────────────────────────────────
486
+ let mtimeMs = 0;
487
+ try {
488
+ if (fs.existsSync(pkgJsonPath)) {
489
+ mtimeMs = (await fs.promises.stat(pkgJsonPath)).mtimeMs;
490
+ }
491
+ } catch { /* ignore stat errors */ }
492
+
493
+ const cacheKey = `${packageRoot}:${mtimeMs}`;
494
+
495
+ if (packageTypeLibsCache.has(cacheKey)) {
496
+ // Apply cached entries, respecting the shared byte budget and seenFiles.
497
+ const cachedEntries = packageTypeLibsCache.get(cacheKey);
498
+ for (const entry of cachedEntries) {
499
+ if (seenFiles.has(entry.seenKey)) continue;
500
+ if (stats.totalBytes + entry.contentBytes > MAX_TOTAL_BYTES) return false;
501
+ stats.totalBytes += entry.contentBytes;
502
+ seenFiles.add(entry.seenKey);
503
+ typeLibraries.push({ moduleName: entry.moduleName, file: entry.file, content: entry.content });
504
+ }
505
+ return true;
506
+ }
507
+
508
+ // ── Cache miss — collect fresh and store ──────────────────────────────────
509
+ const freshEntries = []; // will be stored in cache
510
+
511
+ const pkgJson = fs.existsSync(pkgJsonPath)
512
+ ? JSON.parse(await fs.promises.readFile(pkgJsonPath, "utf8"))
513
+ : {};
514
+ const declarationEntry = resolveDeclarationEntry(packageRoot, pkgJson);
515
+ const declarationFiles = await collectDeclarationFiles(packageRoot);
516
+
517
+ if (declarationFiles.length === 0 && !declarationEntry) {
518
+ // Package has no type declarations at all — cache the empty result and skip.
519
+ packageTypeLibsCache.set(cacheKey, freshEntries);
520
+ return true;
521
+ }
522
+
523
+ // Add a virtual package.json so Monaco's TypeScript service can read the `types` field
524
+ // and resolve the correct entry declaration without guessing.
525
+ if (declarationEntry && pkgJsonPath) {
526
+ const relativeTypes = path.relative(packageRoot, declarationEntry).replaceAll(path.sep, "/");
527
+ const virtualPkgJson = JSON.stringify({ name: moduleName, types: `./${relativeTypes}`, version: pkgJson.version ?? "0.0.0" });
528
+ const virtualPkgJsonPath = toVirtualModuleFile(notebookPath, pkgJsonPath);
529
+ freshEntries.push({
530
+ seenKey: virtualPkgJsonPath,
531
+ moduleName,
532
+ file: virtualPkgJsonPath,
533
+ content: virtualPkgJson,
534
+ contentBytes: Buffer.byteLength(virtualPkgJson)
535
+ });
536
+ }
537
+
538
+ for (const declarationFile of declarationFiles) {
539
+ let content = await fs.promises.readFile(declarationFile, "utf8");
540
+
541
+ // Rewrite `.js` extension imports so they resolve correctly in the virtual FS
542
+ content = rewriteDeclarationImports(content);
543
+
544
+ const contentBytes = Buffer.byteLength(content);
545
+ freshEntries.push({
546
+ seenKey: declarationFile, // absolute path — matches original seenFiles key
547
+ moduleName,
548
+ file: toVirtualModuleFile(notebookPath, declarationFile),
549
+ content,
550
+ contentBytes
551
+ });
552
+ }
553
+
554
+ // Ensure there is an index.d.ts at the package root so TypeScript's standard
555
+ // node_modules resolution finds the package without needing to read package.json.
556
+ const rootIndexPath = path.join(packageRoot, "index.d.ts");
557
+ if (!fs.existsSync(rootIndexPath)) {
558
+ const entrySpecifier = declarationEntry
559
+ ? getDeclarationImportSpecifier(declarationEntry, packageRoot)
560
+ : null;
561
+
562
+ if (entrySpecifier) {
563
+ const aliasSource = `export * from ${JSON.stringify(entrySpecifier)};\nexport { } from ${JSON.stringify(entrySpecifier)};\n`;
564
+ const virtualIndexPath = toVirtualModuleFile(notebookPath, rootIndexPath);
565
+ freshEntries.push({
566
+ seenKey: virtualIndexPath,
567
+ moduleName,
568
+ file: virtualIndexPath,
569
+ content: aliasSource,
570
+ contentBytes: Buffer.byteLength(aliasSource)
571
+ });
572
+ }
573
+ }
574
+
575
+ // Store in cache before applying (so a budget-exceeded early return still caches)
576
+ packageTypeLibsCache.set(cacheKey, freshEntries);
577
+
578
+ // Apply to output arrays, respecting budget and seenFiles.
579
+ for (const entry of freshEntries) {
580
+ if (seenFiles.has(entry.seenKey)) continue;
581
+ if (stats.totalBytes + entry.contentBytes > MAX_TOTAL_BYTES) return false;
582
+ stats.totalBytes += entry.contentBytes;
583
+ seenFiles.add(entry.seenKey);
584
+ typeLibraries.push({ moduleName: entry.moduleName, file: entry.file, content: entry.content });
585
+ }
586
+
587
+ return true;
588
+ }
589
+
590
+ /**
591
+ * Enumerate every package in node_modules (depth-1 + scoped-depth-2).
592
+ * Returns [{moduleName, packageRoot}] for packages that actually have declaration files.
593
+ */
594
+ async function discoverAllNodeModulesPackages(notebookPath) {
595
+ const nodeModulesRoot = getNotebookNodeModulesRoot(notebookPath);
596
+
597
+ if (!fs.existsSync(nodeModulesRoot)) {
598
+ return [];
599
+ }
600
+
601
+ const results = [];
602
+ const entries = await fs.promises.readdir(nodeModulesRoot, { withFileTypes: true });
603
+
604
+ for (const entry of entries) {
605
+ if (!entry.isDirectory()) continue;
606
+
607
+ // Scoped package directory (e.g. @langchain)
608
+ if (entry.name.startsWith("@")) {
609
+ const scopedDir = path.join(nodeModulesRoot, entry.name);
610
+
611
+ try {
612
+ const scopedEntries = await fs.promises.readdir(scopedDir, { withFileTypes: true });
613
+
614
+ for (const scopedEntry of scopedEntries) {
615
+ if (!scopedEntry.isDirectory()) continue;
616
+ const moduleName = `${entry.name}/${scopedEntry.name}`;
617
+ const packageRoot = path.join(scopedDir, scopedEntry.name);
618
+ results.push({ moduleName, packageRoot });
619
+ }
620
+ } catch {
621
+ // Ignore unreadable scoped directories
622
+ }
623
+
624
+ continue;
625
+ }
626
+
627
+ // Skip hidden/internal directories
628
+ if (entry.name.startsWith(".") || entry.name.startsWith("_")) continue;
629
+
630
+ const packageRoot = path.join(nodeModulesRoot, entry.name);
631
+ results.push({ moduleName: entry.name, packageRoot });
632
+ }
633
+
634
+ return results;
635
+ }
636
+
637
+ /**
638
+ * Collect TypeScript declaration libraries for Monaco IntelliSense.
639
+ *
640
+ * @param {string} notebookPath
641
+ * @param {Set<string>|null} filterPackages
642
+ * When non-null, only load type libraries for packages whose moduleName is
643
+ * in this set (plus their direct npm dependencies, expanded automatically).
644
+ * Pass null to load types for every package in node_modules (full mode).
645
+ */
646
+ async function collectTypeLibraries(notebookPath, filterPackages = null) {
647
+ const nodeModulesRoot = getNotebookNodeModulesRoot(notebookPath);
648
+
649
+ // When no filter is active we need a local node_modules to enumerate packages.
650
+ // When a filter IS active we can resolve packages via Node's full module resolution
651
+ // chain (which traverses parent directories), so a local node_modules is not required.
652
+ if (!filterPackages && !fs.existsSync(nodeModulesRoot)) {
653
+ return [];
654
+ }
655
+
656
+ const typeLibraries = [];
657
+ const seenFiles = new Set();
658
+ const seenModules = new Set();
659
+ const stats = { totalBytes: 0 };
660
+
661
+ // Discover packages that live directly in the workspace node_modules.
662
+ const allPackages = await discoverAllNodeModulesPackages(notebookPath);
663
+
664
+ let effectiveFilter = null;
665
+ if (filterPackages && filterPackages.size > 0) {
666
+ effectiveFilter = new Set(filterPackages);
667
+
668
+ // Also pull in @types/<name> companions for each explicitly-requested package.
669
+ // Many packages (e.g. express, mocha) ship no bundled declarations and rely on
670
+ // DefinitelyTyped (@types/express, etc.) for IntelliSense.
671
+ for (const pkgName of filterPackages) {
672
+ if (pkgName.startsWith("@types/")) continue; // already a @types package
673
+ if (pkgName.startsWith("@")) {
674
+ // @scope/name → @types/scope__name (DefinitelyTyped convention)
675
+ effectiveFilter.add(`@types/${pkgName.slice(1).replace("/", "__")}`);
676
+ } else {
677
+ effectiveFilter.add(`@types/${pkgName}`);
678
+ }
679
+ }
680
+
681
+ // Expand filter: include direct dependencies of each requested package.
682
+ for (const pkgName of filterPackages) {
683
+ const pkgRoot = resolvePackageRoot(notebookPath, pkgName);
684
+ if (!pkgRoot) continue;
685
+ try {
686
+ const pkgJsonPath = path.join(pkgRoot, "package.json");
687
+ if (fs.existsSync(pkgJsonPath)) {
688
+ const pkgJson = JSON.parse(await fs.promises.readFile(pkgJsonPath, "utf8"));
689
+ for (const dep of Object.keys(pkgJson.dependencies ?? {})) {
690
+ effectiveFilter.add(dep);
691
+ }
692
+ for (const dep of Object.keys(pkgJson.peerDependencies ?? {})) {
693
+ effectiveFilter.add(dep);
694
+ }
695
+ }
696
+ } catch { /* ignore */ }
697
+ }
698
+
699
+ // For every package in the effective filter, fall back to Node's full module
700
+ // resolution (require.resolve traverses parent directories) to find packages
701
+ // that are accessible but not in the workspace's own node_modules.
702
+ // This handles: packages in the marsbook server's own node_modules, globally
703
+ // installed packages, and monorepo hoisted packages.
704
+ const localModuleNames = new Set(allPackages.map(p => p.moduleName));
705
+ for (const pkgName of effectiveFilter) {
706
+ if (localModuleNames.has(pkgName)) continue;
707
+
708
+ let pkgRoot = null;
709
+
710
+ if (pkgName.startsWith("@types/")) {
711
+ // @types/* packages contain only .d.ts files — no JavaScript entry point.
712
+ // require.resolve() always throws MODULE_NOT_FOUND for them, so we must
713
+ // locate them via a direct filesystem walk instead.
714
+ pkgRoot = findAtTypesRoot(notebookPath, pkgName);
715
+ } else {
716
+ pkgRoot = resolvePackageRoot(notebookPath, pkgName);
717
+ }
718
+
719
+ if (pkgRoot) {
720
+ allPackages.push({ moduleName: pkgName, packageRoot: pkgRoot });
721
+ localModuleNames.add(pkgName);
722
+ }
723
+ }
724
+ }
725
+
726
+ // Sort packages so that smaller packages come first — this maximises the number
727
+ // of packages that fit within the total byte budget before it is exhausted.
728
+ // We approximate package "size" by the number of declaration files it ships.
729
+ const packageSizes = new Map();
730
+ await Promise.all(
731
+ allPackages.map(async ({ moduleName, packageRoot }) => {
732
+ // Skip size-estimation for packages outside the filter (no need to sort them)
733
+ if (effectiveFilter && !effectiveFilter.has(moduleName)) return;
734
+ try {
735
+ const files = await collectDeclarationFiles(packageRoot);
736
+ packageSizes.set(moduleName, files.length);
737
+ } catch {
738
+ packageSizes.set(moduleName, 0);
739
+ }
740
+ })
741
+ );
742
+
743
+ const priorityPackages = [
744
+ "@langchain/core",
745
+ "@langchain/groq",
746
+ "groq-sdk"
747
+ ];
748
+
749
+ allPackages.sort((a, b) => {
750
+ const aPriority = priorityPackages.some(p => a.moduleName.startsWith(p)) ? -1 : 0;
751
+ const bPriority = priorityPackages.some(p => b.moduleName.startsWith(p)) ? -1 : 0;
752
+
753
+ if (aPriority !== bPriority) {
754
+ return aPriority - bPriority;
755
+ }
756
+
757
+ const sizeA = packageSizes.get(a.moduleName) ?? 0;
758
+ const sizeB = packageSizes.get(b.moduleName) ?? 0;
759
+
760
+ return sizeA - sizeB;
761
+ });
762
+
763
+ for (const { moduleName, packageRoot } of allPackages) {
764
+ // Skip packages outside the effective filter when one is active.
765
+ if (effectiveFilter && !effectiveFilter.has(moduleName)) continue;
766
+
767
+ if (seenModules.has(moduleName)) continue;
768
+ seenModules.add(moduleName);
769
+
770
+ const ok = await addPackageTypeLibraries(notebookPath, moduleName, packageRoot, typeLibraries, seenFiles, stats);
771
+
772
+ if (!ok) {
773
+ // Size budget exhausted — return what we have
774
+ return typeLibraries;
775
+ }
776
+ }
777
+
778
+ return typeLibraries;
779
+ }
780
+
781
+ /**
782
+ * Like collectDeclaredPackageExports but with per-package mtime-based caching
783
+ * so repeated calls (e.g. each time /api/suggestions fires) hit memory instead
784
+ * of parsing TypeScript AST from disk every time.
785
+ */
786
+ async function collectDeclaredPackageExportsCached(notebookPath, moduleNames) {
787
+ const exportEntries = [];
788
+
789
+ for (const moduleName of moduleNames) {
790
+ // @types/ packages are not directly imported by users; skip them here.
791
+ // The fallback inside collectDeclaredPackageExports already handles them.
792
+ if (moduleName.startsWith("@types/")) continue;
793
+
794
+ const packageRoot = resolvePackageRoot(notebookPath, moduleName);
795
+
796
+ // Some packages (e.g. "express") have no bundled declarations but DO have a
797
+ // companion "@types/express" package. When the package's own root is missing
798
+ // or lacks a package.json, we still want to attempt the @types fallback, so we
799
+ // use the @types package root for the cache key instead.
800
+ //
801
+ // IMPORTANT: @types/* packages have no JavaScript entry, so require.resolve()
802
+ // fails for them. Use findAtTypesRoot() (filesystem walk) instead.
803
+ const atTypesName = moduleName.startsWith("@")
804
+ ? `@types/${moduleName.slice(1).replace("/", "__")}`
805
+ : `@types/${moduleName}`;
806
+ const atTypesRoot = !packageRoot ? findAtTypesRoot(notebookPath, atTypesName) : null;
807
+
808
+ // If neither the package nor its @types companion can be found, skip.
809
+ const effectiveRoot = packageRoot ?? atTypesRoot;
810
+ if (!effectiveRoot) continue;
811
+
812
+ const pkgJsonPath = path.join(effectiveRoot, "package.json");
813
+ if (!fs.existsSync(pkgJsonPath)) continue;
814
+
815
+ let mtimeMs = 0;
816
+ try { mtimeMs = (await fs.promises.stat(pkgJsonPath)).mtimeMs; } catch { /* ignore */ }
817
+
818
+ const cacheKey = `${effectiveRoot}:${mtimeMs}`;
819
+
820
+ if (packageExportsCache.has(cacheKey)) {
821
+ exportEntries.push(...packageExportsCache.get(cacheKey));
822
+ continue;
823
+ }
824
+
825
+ // Cache miss — parse this single package (with @types fallback) and store result.
826
+ const entries = await collectDeclaredPackageExports(notebookPath, [moduleName]);
827
+ packageExportsCache.set(cacheKey, entries);
828
+ exportEntries.push(...entries);
829
+ }
830
+
831
+ return exportEntries;
832
+ }
833
+
834
+ async function ensurePackageJson(directoryPath) {
835
+ const packageJsonPath = path.join(directoryPath, "package.json");
836
+
837
+ if (!fs.existsSync(packageJsonPath)) {
838
+ const packageJson = {
839
+ name: path.basename(directoryPath).toLowerCase().replace(/[^a-z0-9-]+/g, "-") || "nodebook-project",
840
+ private: true,
841
+ type: "module"
842
+ };
843
+
844
+ await fs.promises.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2));
845
+ }
846
+ }
847
+
848
+ async function installPackages(notebookPath, packages) {
849
+ const workingDirectory = path.dirname(notebookPath);
850
+ await fs.promises.mkdir(workingDirectory, { recursive: true });
851
+ await ensurePackageJson(workingDirectory);
852
+
853
+ return new Promise((resolve) => {
854
+ const child = spawn("npm", ["install", ...packages], {
855
+ cwd: workingDirectory,
856
+ env: process.env
857
+ });
858
+
859
+ let stdout = "";
860
+ let stderr = "";
861
+
862
+ child.stdout.on("data", (chunk) => {
863
+ stdout += chunk.toString("utf8");
864
+ });
865
+
866
+ child.stderr.on("data", (chunk) => {
867
+ stderr += chunk.toString("utf8");
868
+ });
869
+
870
+ child.on("close", (code) => {
871
+ resolve({
872
+ ok: code === 0,
873
+ code,
874
+ stdout,
875
+ stderr
876
+ });
877
+ });
878
+ });
879
+ }
880
+
881
+ async function runShellCommand(command, cwd) {
882
+ // Ensure a package.json exists so npm commands work correctly in the directory
883
+ await ensurePackageJson(cwd);
884
+
885
+ return new Promise((resolve) => {
886
+ const child = spawn(command, {
887
+ cwd,
888
+ // Disable npm's interactive progress bar so it doesn't hang on non-TTY pipes.
889
+ // Also ensure PATH is inherited so nvm/volta-managed npm binaries are found.
890
+ env: { ...process.env, NPM_CONFIG_PROGRESS: "false", NPM_CONFIG_FUND: "false" },
891
+ shell: true,
892
+ // Explicitly close stdin — prevents npm (and other tools) from blocking
893
+ // waiting for input that will never arrive.
894
+ stdio: ["ignore", "pipe", "pipe"]
895
+ });
896
+
897
+ let stdout = "";
898
+ let stderr = "";
899
+
900
+ child.stdout.on("data", (chunk) => {
901
+ stdout += chunk.toString("utf8");
902
+ });
903
+
904
+ child.stderr.on("data", (chunk) => {
905
+ stderr += chunk.toString("utf8");
906
+ });
907
+
908
+ child.on("close", (code) => {
909
+ resolve({
910
+ ok: code === 0,
911
+ code,
912
+ stdout,
913
+ stderr
914
+ });
915
+ });
916
+
917
+ child.on("error", (err) => {
918
+ resolve({
919
+ ok: false,
920
+ code: -1,
921
+ stdout,
922
+ stderr: stderr + err.message
923
+ });
924
+ });
925
+ });
926
+ }
927
+
928
+ function normalizeRepositoryUrl(repository) {
929
+ const rawValue =
930
+ typeof repository === "string"
931
+ ? repository
932
+ : repository && typeof repository === "object" && typeof repository.url === "string"
933
+ ? repository.url
934
+ : null;
935
+
936
+ if (!rawValue) {
937
+ return null;
938
+ }
939
+
940
+ return rawValue
941
+ .replace(/^git\+/, "")
942
+ .replace(/^git:\/\//, "https://")
943
+ .replace(/\.git$/, "");
944
+ }
945
+
946
+ async function fetchPackageDocs(packageName) {
947
+ const registryUrl = `https://registry.npmjs.org/${encodeURIComponent(packageName)}`;
948
+ const response = await fetch(registryUrl, {
949
+ headers: {
950
+ accept: "application/json"
951
+ },
952
+ signal: AbortSignal.timeout(10_000)
953
+ });
954
+
955
+ if (!response.ok) {
956
+ throw new Error(`Unable to fetch docs for ${packageName}`);
957
+ }
958
+
959
+ const metadata = await response.json();
960
+ const latestVersion = metadata["dist-tags"]?.latest ?? null;
961
+ const latestManifest =
962
+ latestVersion && metadata.versions && typeof metadata.versions === "object"
963
+ ? metadata.versions[latestVersion]
964
+ : null;
965
+
966
+ return {
967
+ name: metadata.name ?? packageName,
968
+ version: latestVersion ?? latestManifest?.version ?? null,
969
+ description: latestManifest?.description ?? metadata.description ?? "",
970
+ homepage: latestManifest?.homepage ?? metadata.homepage ?? null,
971
+ repositoryUrl: normalizeRepositoryUrl(latestManifest?.repository ?? metadata.repository),
972
+ npmUrl: `https://www.npmjs.com/package/${packageName}`,
973
+ readme: metadata.readme ?? latestManifest?.readme ?? ""
974
+ };
975
+ }
976
+
977
+ async function formatNotebookForSave(notebook) {
978
+ const normalized = normalizeNotebook(notebook);
979
+ const formattedCells = await Promise.all(
980
+ normalized.cells.map(async (cell) => {
981
+ if (cell.type === "prompt") {
982
+ return cell;
983
+ }
984
+
985
+ let parser = null;
986
+ let plugins = [];
987
+
988
+ if (cell.type === "markdown") {
989
+ parser = "markdown";
990
+ plugins = [prettierPluginMarkdown];
991
+ } else if ((cell.language ?? "typescript") === "javascript") {
992
+ parser = "babel";
993
+ plugins = [prettierPluginBabel, prettierPluginEstree];
994
+ } else {
995
+ parser = "typescript";
996
+ plugins = [prettierPluginTypeScript, prettierPluginEstree];
997
+ }
998
+
999
+ try {
1000
+ const source = await prettier.format(cell.source ?? "", {
1001
+ parser,
1002
+ plugins,
1003
+ printWidth: 100,
1004
+ tabWidth: 2,
1005
+ semi: false,
1006
+ singleQuote: false
1007
+ });
1008
+
1009
+ return {
1010
+ ...cell,
1011
+ source
1012
+ };
1013
+ } catch {
1014
+ return cell;
1015
+ }
1016
+ })
1017
+ );
1018
+
1019
+ return {
1020
+ ...normalized,
1021
+ cells: formattedCells
1022
+ };
1023
+ }
1024
+
1025
+ function resolveAiConfig(env = {}, notebookMeta = {}, promptConfig = {}) {
1026
+ const provider = promptConfig.provider ?? notebookMeta.provider ?? "groq";
1027
+ const requestedModel = promptConfig.model ?? notebookMeta.model ?? env.GROQ_MODEL ?? "llama-3.3-70b-versatile";
1028
+ const model = GROQ_MODELS_FALLBACK.includes(requestedModel) ? requestedModel : requestedModel || "llama-3.3-70b-versatile";
1029
+ const temperature = Number.isFinite(promptConfig.temperature) ? Number(promptConfig.temperature) : 0.2;
1030
+
1031
+ if (provider !== "groq") {
1032
+ throw new Error(`Unsupported AI provider: ${provider}`);
1033
+ }
1034
+
1035
+ const apiKey = env.GROQ_API_KEY || process.env.GROQ_API_KEY;
1036
+ const baseUrl = (env.GROQ_BASE_URL || process.env.GROQ_BASE_URL || "https://api.groq.com/openai/v1").replace(/\/+$/, "");
1037
+
1038
+ if (!apiKey) {
1039
+ throw new Error("Missing GROQ_API_KEY");
1040
+ }
1041
+
1042
+ return {
1043
+ provider,
1044
+ model,
1045
+ temperature,
1046
+ apiKey,
1047
+ baseUrl
1048
+ };
1049
+ }
1050
+
1051
+ async function requestAiCompletion(config, messages) {
1052
+ const response = await fetch(`${config.baseUrl}/chat/completions`, {
1053
+ method: "POST",
1054
+ headers: {
1055
+ "content-type": "application/json",
1056
+ authorization: `Bearer ${config.apiKey}`
1057
+ },
1058
+ body: JSON.stringify({
1059
+ model: config.model,
1060
+ temperature: config.temperature,
1061
+ messages
1062
+ }),
1063
+ signal: AbortSignal.timeout(60_000)
1064
+ });
1065
+
1066
+ if (!response.ok) {
1067
+ const text = await response.text();
1068
+ throw new Error(text || "AI completion failed");
1069
+ }
1070
+
1071
+ const data = await response.json();
1072
+ return data.choices?.[0]?.message?.content ?? "";
1073
+ }
1074
+
1075
+ async function streamAiCompletion(config, messages, response) {
1076
+ const upstream = await fetch(`${config.baseUrl}/chat/completions`, {
1077
+ method: "POST",
1078
+ headers: {
1079
+ "content-type": "application/json",
1080
+ authorization: `Bearer ${config.apiKey}`
1081
+ },
1082
+ body: JSON.stringify({
1083
+ model: config.model,
1084
+ temperature: config.temperature,
1085
+ stream: true,
1086
+ stream_options: { include_usage: true },
1087
+ messages
1088
+ }),
1089
+ signal: AbortSignal.timeout(60_000)
1090
+ });
1091
+
1092
+ if (!upstream.ok || !upstream.body) {
1093
+ const text = await upstream.text();
1094
+ throw new Error(text || "AI stream failed");
1095
+ }
1096
+
1097
+ response.writeHead(200, {
1098
+ "content-type": "text/plain; charset=utf-8",
1099
+ "cache-control": "no-cache",
1100
+ connection: "keep-alive"
1101
+ });
1102
+
1103
+ const decoder = new TextDecoder();
1104
+ let buffer = "";
1105
+ let usageData = null;
1106
+
1107
+ for await (const chunk of upstream.body) {
1108
+ buffer += decoder.decode(chunk, { stream: true });
1109
+ const lines = buffer.split("\n");
1110
+ buffer = lines.pop() ?? "";
1111
+
1112
+ for (const line of lines) {
1113
+ const trimmed = line.trim();
1114
+ if (!trimmed.startsWith("data:")) {
1115
+ continue;
1116
+ }
1117
+
1118
+ const payload = trimmed.slice(5).trim();
1119
+ if (!payload || payload === "[DONE]") {
1120
+ continue;
1121
+ }
1122
+
1123
+ try {
1124
+ const data = JSON.parse(payload);
1125
+ const content = data.choices?.[0]?.delta?.content;
1126
+ if (content) {
1127
+ response.write(content);
1128
+ }
1129
+ // Capture usage data from any chunk that has it
1130
+ if (data.usage && typeof data.usage.prompt_tokens === "number") {
1131
+ usageData = data.usage;
1132
+ }
1133
+ } catch {
1134
+ // Ignore malformed SSE chunks from upstream
1135
+ }
1136
+ }
1137
+ }
1138
+
1139
+ // Send token usage as a special sentinel line so the client can read real counts
1140
+ if (usageData) {
1141
+ response.write(`\n\x02TOKEN_USAGE:${JSON.stringify({ in: usageData.prompt_tokens, out: usageData.completion_tokens })}\n`);
1142
+ }
1143
+
1144
+ response.end();
1145
+ }
1146
+
1147
+ function resolveOpenablePath(workspaceRoot, notebooksDir, requestedPath, options = {}) {
1148
+ const absolutePath = resolveWorkspaceOpenPath(workspaceRoot, notebooksDir, requestedPath, {
1149
+ allowNotebookCreate: options.allowNotebookCreate ?? false,
1150
+ existsSync: fs.existsSync
1151
+ });
1152
+
1153
+ if (!absolutePath || !absolutePath.startsWith(workspaceRoot)) {
1154
+ throw new Error("Path is outside the workspace");
1155
+ }
1156
+
1157
+ return absolutePath;
1158
+ }
1159
+
1160
+ async function readOpenableFile(workspaceRoot, notebooksDir, filePath) {
1161
+ const kind = getOpenableFileKind(filePath);
1162
+
1163
+ if (!kind) {
1164
+ throw new Error("File type is not supported");
1165
+ }
1166
+
1167
+ if (kind === "notebook") {
1168
+ return {
1169
+ kind,
1170
+ path: filePath,
1171
+ appPath: getAppPathFromWorkspacePath(workspaceRoot, notebooksDir, filePath),
1172
+ notebook: await ensureNotebook(filePath)
1173
+ };
1174
+ }
1175
+
1176
+ if (kind === "text") {
1177
+ return {
1178
+ kind,
1179
+ path: filePath,
1180
+ appPath: getAppPathFromWorkspacePath(workspaceRoot, notebooksDir, filePath),
1181
+ name: path.basename(filePath),
1182
+ extension: path.extname(filePath).toLowerCase(),
1183
+ content: await fs.promises.readFile(filePath, "utf8")
1184
+ };
1185
+ }
1186
+
1187
+ return {
1188
+ kind,
1189
+ path: filePath,
1190
+ appPath: getAppPathFromWorkspacePath(workspaceRoot, notebooksDir, filePath),
1191
+ name: path.basename(filePath),
1192
+ extension: path.extname(filePath).toLowerCase(),
1193
+ contentUrl: `/api/file/content?path=${encodeURIComponent(filePath)}`
1194
+ };
1195
+ }
1196
+
1197
+ async function serveFile(rootDirectory, requestPath, response) {
1198
+ const absolutePath = path.join(rootDirectory, requestPath);
1199
+
1200
+ if (!absolutePath.startsWith(rootDirectory) || !fs.existsSync(absolutePath)) {
1201
+ notFound(response);
1202
+ return;
1203
+ }
1204
+
1205
+ const extension = path.extname(absolutePath);
1206
+ response.writeHead(200, {
1207
+ "content-type": MIME_TYPES[extension] ?? "application/octet-stream"
1208
+ });
1209
+ fs.createReadStream(absolutePath).pipe(response);
1210
+ }
1211
+
1212
+ async function serveStaticAsset(requestPath, response) {
1213
+ const safePath = requestPath === "/" ? "/index.html" : requestPath;
1214
+ const absolutePath = path.join(publicDir, safePath);
1215
+
1216
+ if (absolutePath.startsWith(publicDir) && fs.existsSync(absolutePath)) {
1217
+ await serveFile(publicDir, safePath, response);
1218
+ return;
1219
+ }
1220
+
1221
+ await serveFile(publicDir, "/index.html", response);
1222
+ }
1223
+
1224
+ export async function createServer(options = {}) {
1225
+ const workspaceRoot = path.resolve(options.workspaceRoot ?? process.cwd());
1226
+ // The workspace root IS the notebooks directory — users place .ijsnb files
1227
+ // directly in their project folder (or any subfolder), not in a separate
1228
+ // "notebooks/" subdirectory. The /notebooks/ segment only appears in the
1229
+ // browser URL as a cosmetic prefix.
1230
+ const notebooksDir = workspaceRoot;
1231
+ const sessions = new Map();
1232
+ const pendingInputResolvers = new Map(); // runId -> Array<resolve>
1233
+
1234
+ // Ensure the workspace root exists (in case the user pointed to a new dir)
1235
+ await fs.promises.mkdir(workspaceRoot, { recursive: true });
1236
+
1237
+ function getSession(notebookPath) {
1238
+ if (!sessions.has(notebookPath)) {
1239
+ sessions.set(notebookPath, new KernelSession(notebookPath));
1240
+ }
1241
+
1242
+ return sessions.get(notebookPath);
1243
+ }
1244
+
1245
+ const server = http.createServer(async (request, response) => {
1246
+ try {
1247
+ const requestUrl = new URL(request.url, "http://127.0.0.1");
1248
+
1249
+ if (request.method === "GET" && requestUrl.pathname === "/api/notebook") {
1250
+ const notebookPath = resolveNotebookPath(workspaceRoot, notebooksDir, requestUrl.searchParams.get("path"));
1251
+ const notebook = await ensureNotebook(notebookPath);
1252
+
1253
+ json(response, 200, {
1254
+ notebookPath,
1255
+ notebook
1256
+ });
1257
+ return;
1258
+ }
1259
+
1260
+ if (request.method === "GET" && requestUrl.pathname === "/api/open") {
1261
+ const requestedPath = requestUrl.searchParams.get("path");
1262
+ const absolutePath = requestedPath
1263
+ ? resolveOpenablePath(workspaceRoot, notebooksDir, requestedPath, {
1264
+ allowNotebookCreate: String(requestedPath).endsWith(NOTEBOOK_EXTENSION)
1265
+ })
1266
+ : path.join(workspaceRoot, "startup.ijsnb");
1267
+ const resource = await readOpenableFile(workspaceRoot, notebooksDir, absolutePath);
1268
+
1269
+ json(response, 200, resource);
1270
+ return;
1271
+ }
1272
+
1273
+ // Check whether a workspace file exists WITHOUT creating it.
1274
+ // Returns { exists: true|false }
1275
+ if (request.method === "GET" && requestUrl.pathname === "/api/stat") {
1276
+ const rawPath = requestUrl.searchParams.get("path") ?? "";
1277
+ let filePath;
1278
+ try {
1279
+ filePath = resolveNotebookPath(workspaceRoot, notebooksDir, rawPath);
1280
+ } catch {
1281
+ json(response, 200, { exists: false });
1282
+ return;
1283
+ }
1284
+ json(response, 200, { exists: fs.existsSync(filePath) });
1285
+ return;
1286
+ }
1287
+
1288
+ if (request.method === "GET" && requestUrl.pathname === "/api/files") {
1289
+ const directoryPath = resolveWorkspacePath(workspaceRoot, requestUrl.searchParams.get("path"));
1290
+ const entries = await listDirectoryEntries(workspaceRoot, directoryPath);
1291
+
1292
+ json(response, 200, {
1293
+ rootPath: workspaceRoot,
1294
+ directoryPath,
1295
+ entries
1296
+ });
1297
+ return;
1298
+ }
1299
+
1300
+ if (request.method === "POST" && requestUrl.pathname === "/api/notebook/save") {
1301
+ const body = await readRequestBody(request);
1302
+ const notebookPath = resolveNotebookPath(workspaceRoot, notebooksDir, body.path);
1303
+ const formattedNotebook = await formatNotebookForSave(body.notebook);
1304
+ const requestedNextPath = typeof body.nextPath === "string" && body.nextPath.trim() ? body.nextPath : null;
1305
+ const nextPath = requestedNextPath
1306
+ ? resolveOpenablePath(workspaceRoot, notebooksDir, requestedNextPath, { allowNotebookCreate: true })
1307
+ : deriveNotebookPathFromTitle(notebookPath, formattedNotebook?.metadata?.title);
1308
+ const saved = await saveNotebookAtPath(notebookPath, formattedNotebook, nextPath);
1309
+
1310
+ if (notebookPath !== saved.notebookPath && sessions.has(notebookPath)) {
1311
+ const session = sessions.get(notebookPath);
1312
+ sessions.delete(notebookPath);
1313
+ session.notebookPath = saved.notebookPath;
1314
+ sessions.set(saved.notebookPath, session);
1315
+ }
1316
+
1317
+ json(response, 200, {
1318
+ ok: true,
1319
+ notebookPath: saved.notebookPath,
1320
+ appPath: getAppPathFromWorkspacePath(workspaceRoot, notebooksDir, saved.notebookPath),
1321
+ notebook: saved.notebook
1322
+ });
1323
+ return;
1324
+ }
1325
+
1326
+ if (request.method === "POST" && requestUrl.pathname === "/api/format") {
1327
+ const body = await readRequestBody(request);
1328
+ const notebook = await formatNotebookForSave(body.notebook);
1329
+ json(response, 200, { notebook });
1330
+ return;
1331
+ }
1332
+
1333
+ if (request.method === "GET" && requestUrl.pathname === "/api/file/content") {
1334
+ const filePath = resolveOpenablePath(workspaceRoot, notebooksDir, requestUrl.searchParams.get("path"));
1335
+ const fileKind = getOpenableFileKind(filePath);
1336
+
1337
+ if (fileKind !== "image" && fileKind !== "pdf") {
1338
+ json(response, 400, { error: "Binary content is only available for images and PDFs" });
1339
+ return;
1340
+ }
1341
+
1342
+ response.writeHead(200, {
1343
+ "content-type": MIME_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream"
1344
+ });
1345
+ fs.createReadStream(filePath).pipe(response);
1346
+ return;
1347
+ }
1348
+
1349
+ if (request.method === "GET" && requestUrl.pathname === "/api/suggestions") {
1350
+ const rawPath = requestUrl.searchParams.get("path");
1351
+ if (!rawPath) {
1352
+ // No path → nothing to suggest, and we must NOT auto-create a notebook
1353
+ json(response, 200, { modules: [], installedPackages: [], typeLibraries: [], packageExports: [] });
1354
+ return;
1355
+ }
1356
+ const notebookPath = resolveNotebookPath(workspaceRoot, notebooksDir, rawPath);
1357
+
1358
+ // Guard: do NOT create a .ijsnb file when suggestions are requested for a
1359
+ // standalone text file (e.g. data_flow.js opened directly in the editor).
1360
+ // resolveNotebookPath always appends NOTEBOOK_EXTENSION, so without this
1361
+ // check we would spuriously create "data_flow.js.ijsnb" next to the JS file.
1362
+ const rawExt = path.extname(String(rawPath)).toLowerCase();
1363
+ const isNonNotebookFile = rawExt !== "" && rawExt !== NOTEBOOK_EXTENSION;
1364
+ if (!isNonNotebookFile) {
1365
+ await ensureNotebook(notebookPath);
1366
+ }
1367
+ const session = getSession(notebookPath);
1368
+
1369
+ // ── Field selection ────────────────────────────────────────────────
1370
+ // ?fields=modules,installedPackages → fast, no type work
1371
+ // ?fields=modules,installedPackages,typeLibraries,packageExports → full (default)
1372
+ const fieldsParam = requestUrl.searchParams.get("fields");
1373
+ const fields = fieldsParam
1374
+ ? new Set(fieldsParam.split(",").map(f => f.trim()))
1375
+ : new Set(["modules", "installedPackages", "typeLibraries", "packageExports"]);
1376
+
1377
+ // ── Package filter ─────────────────────────────────────────────────
1378
+ // ?packages=pkg1,pkg2 → only compute type data for these packages
1379
+ // Omit the param entirely → compute for all installed packages (full mode)
1380
+ // ?packages= (empty value) → treat same as omitted (safety net)
1381
+ const packagesParamRaw = requestUrl.searchParams.get("packages");
1382
+ // `has` distinguishes "param present but empty" from "param absent".
1383
+ const packagesParamPresent = requestUrl.searchParams.has("packages");
1384
+ const filterPackages = (packagesParamPresent && packagesParamRaw)
1385
+ ? new Set(packagesParamRaw.split(",").map(p => p.trim()).filter(Boolean))
1386
+ : null;
1387
+
1388
+ const result = {};
1389
+
1390
+ // modules + installedPackages are always cheap — compute only when requested
1391
+ let installedPackages = null;
1392
+ if (fields.has("modules")) {
1393
+ result.modules = await session.listInstalledModules();
1394
+ }
1395
+ if (fields.has("installedPackages")) {
1396
+ installedPackages = await session.listDeclaredPackages();
1397
+ result.installedPackages = installedPackages;
1398
+ }
1399
+
1400
+ // typeLibraries — expensive disk scan, support filtering + caching
1401
+ if (fields.has("typeLibraries")) {
1402
+ result.typeLibraries = await collectTypeLibraries(notebookPath, filterPackages);
1403
+ }
1404
+
1405
+ // packageExports — TypeScript AST parsing, support filtering + caching
1406
+ if (fields.has("packageExports")) {
1407
+ // Determine which packages to extract exports for.
1408
+ // When a package filter is given, use it (only explicitly-imported pkgs
1409
+ // need auto-complete export lists). Otherwise fall back to all installed packages.
1410
+ let exportPackages;
1411
+ if (filterPackages && filterPackages.size > 0) {
1412
+ exportPackages = [...filterPackages];
1413
+ } else {
1414
+ exportPackages = installedPackages ?? await session.listDeclaredPackages();
1415
+ }
1416
+ result.packageExports = await collectDeclaredPackageExportsCached(notebookPath, exportPackages);
1417
+ }
1418
+
1419
+ // Use compact JSON — typeLibraries can be many MB and pretty-printing
1420
+ // adds 15-20% overhead on a payload that's purely machine-to-machine.
1421
+ json(response, 200, result, /* compact= */ true);
1422
+ return;
1423
+ }
1424
+
1425
+ // ── /api/local-files — list .js/.ts files reachable from the notebook ───
1426
+ // Returns files with their relative import paths so Monaco can resolve both
1427
+ // `import { x } from "./controller/app"` (subdirectory) and
1428
+ // `import { y } from "../utils"` (parent directory).
1429
+ // Response shape: { files: [{ name: "controller/app.js", content: "..." }, ...] }
1430
+ // name is the import-ready relative path (without leading "./")
1431
+ // parent-dir files use "../<filename>" as the name.
1432
+ if (request.method === "GET" && requestUrl.pathname === "/api/local-files") {
1433
+ const rawPath = requestUrl.searchParams.get("path");
1434
+ if (!rawPath) {
1435
+ json(response, 200, { files: [] });
1436
+ return;
1437
+ }
1438
+
1439
+ const notebookPath = resolveNotebookPath(workspaceRoot, notebooksDir, rawPath);
1440
+ const notebookDir = path.dirname(notebookPath);
1441
+ const files = [];
1442
+
1443
+ // Directories to never descend into
1444
+ const SKIP_DIRS = new Set([
1445
+ "node_modules", ".git", ".nodebook-cache", ".cache",
1446
+ "dist", "build", "out", ".next", ".nuxt", ".svelte-kit",
1447
+ "coverage", ".turbo", ".vercel"
1448
+ ]);
1449
+ const JS_EXTS = new Set([".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx"]);
1450
+ const MAX_FILES = 300;
1451
+ const MAX_DEPTH = 5;
1452
+ const MAX_FILE_BYTES = 256_000; // 256 KB per file
1453
+
1454
+ // Read a single file and push it if it's within size limits
1455
+ const pushFile = async (absPath, relName) => {
1456
+ if (files.length >= MAX_FILES) return;
1457
+ try {
1458
+ const stat = await fs.promises.stat(absPath);
1459
+ if (stat.size > MAX_FILE_BYTES) return;
1460
+ const content = await fs.promises.readFile(absPath, "utf8");
1461
+ files.push({ name: relName, content });
1462
+ } catch {
1463
+ // skip unreadable / binary files
1464
+ }
1465
+ };
1466
+
1467
+ // Recursively scan a directory. relPrefix is the path relative to notebookDir
1468
+ // (empty string for the root, "controller" for a subdirectory, etc.).
1469
+ const scanDir = async (absDir, relPrefix, depth) => {
1470
+ if (files.length >= MAX_FILES || depth > MAX_DEPTH) return;
1471
+ let entries;
1472
+ try {
1473
+ entries = await fs.promises.readdir(absDir, { withFileTypes: true });
1474
+ } catch {
1475
+ return;
1476
+ }
1477
+ for (const entry of entries) {
1478
+ if (files.length >= MAX_FILES) break;
1479
+ if (entry.isDirectory()) {
1480
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
1481
+ const childRel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
1482
+ await scanDir(path.join(absDir, entry.name), childRel, depth + 1);
1483
+ } else if (entry.isFile()) {
1484
+ if (!JS_EXTS.has(path.extname(entry.name).toLowerCase())) continue;
1485
+ const relName = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
1486
+ await pushFile(path.join(absDir, entry.name), relName);
1487
+ }
1488
+ }
1489
+ };
1490
+
1491
+ // Scan the notebook's own directory (and all subdirectories)
1492
+ await scanDir(notebookDir, "", 0);
1493
+
1494
+ // Also scan the immediate parent directory (flat, not recursive) so that
1495
+ // `import { x } from "../utils"` works when the notebook is in a subdirectory.
1496
+ // Only do this if the parent is still inside the workspace root.
1497
+ const parentDir = path.dirname(notebookDir);
1498
+ if (parentDir !== notebookDir && parentDir.startsWith(workspaceRoot)) {
1499
+ let parentEntries = [];
1500
+ try { parentEntries = await fs.promises.readdir(parentDir, { withFileTypes: true }); } catch { /* ignore */ }
1501
+ for (const entry of parentEntries) {
1502
+ if (files.length >= MAX_FILES) break;
1503
+ if (!entry.isFile()) continue;
1504
+ if (!JS_EXTS.has(path.extname(entry.name).toLowerCase())) continue;
1505
+ await pushFile(path.join(parentDir, entry.name), `../${entry.name}`);
1506
+ }
1507
+ }
1508
+
1509
+ json(response, 200, { files }, /* compact= */ true);
1510
+ return;
1511
+ }
1512
+
1513
+ if (request.method === "GET" && requestUrl.pathname === "/api/ai/config") {
1514
+ const notebookPath = resolveNotebookPath(workspaceRoot, notebooksDir, requestUrl.searchParams.get("path"));
1515
+ const notebook = await ensureNotebook(notebookPath);
1516
+ const env = notebook.metadata?.env ?? {};
1517
+ const apiKey = env.GROQ_API_KEY || process.env.GROQ_API_KEY;
1518
+ const models = await fetchGroqModels(apiKey);
1519
+ const savedModel = notebook.metadata?.ai?.model;
1520
+
1521
+ json(response, 200, {
1522
+ hasGroqKey: Boolean(apiKey),
1523
+ models,
1524
+ defaultModel: savedModel && models.includes(savedModel) ? savedModel : models[0]
1525
+ });
1526
+ return;
1527
+ }
1528
+
1529
+ if (request.method === "GET" && requestUrl.pathname === "/api/package-docs") {
1530
+ const notebookPath = resolveNotebookPath(workspaceRoot, notebooksDir, requestUrl.searchParams.get("path"));
1531
+ const packageName = String(requestUrl.searchParams.get("package") ?? "").trim();
1532
+
1533
+ if (!packageName) {
1534
+ json(response, 400, { error: "No package provided" });
1535
+ return;
1536
+ }
1537
+
1538
+ await ensureNotebook(notebookPath);
1539
+ const docs = await fetchPackageDocs(packageName);
1540
+ json(response, 200, docs);
1541
+ return;
1542
+ }
1543
+
1544
+ if (request.method === "POST" && requestUrl.pathname === "/api/execute") {
1545
+ const body = await readRequestBody(request);
1546
+ const notebookPath = resolveNotebookPath(workspaceRoot, notebooksDir, body.path);
1547
+ await ensureNotebook(notebookPath);
1548
+ const session = getSession(notebookPath);
1549
+ // Ensure any stray timers from a previous run are cleared before starting a new one
1550
+ session.cancelExecution();
1551
+ const stream = Boolean(body.stream);
1552
+
1553
+ if (stream) {
1554
+ response.writeHead(200, { "content-type": "application/x-ndjson; charset=utf-8" });
1555
+ const runId = randomUUID();
1556
+ let cleanedUp = false;
1557
+ const cleanup = () => {
1558
+ if (cleanedUp) return;
1559
+ cleanedUp = true;
1560
+ pendingInputResolvers.delete(runId);
1561
+ };
1562
+ const cancel = () => {
1563
+ session.cancelExecution();
1564
+ cleanup();
1565
+ };
1566
+ request.on("aborted", cancel);
1567
+ response.on("close", cancel);
1568
+ pendingInputResolvers.set(runId, []);
1569
+ const requestInput = (promptText) => {
1570
+ return new Promise((resolve) => {
1571
+ const queue = pendingInputResolvers.get(runId) ?? [];
1572
+ queue.push(resolve);
1573
+ pendingInputResolvers.set(runId, queue);
1574
+ response.write(JSON.stringify({ kind: "input_request", runId, prompt: promptText ?? "" }) + "\n");
1575
+ });
1576
+ };
1577
+ const result = await session.execute(
1578
+ body.code ?? "",
1579
+ body.cellId,
1580
+ body.env ?? {},
1581
+ body.language ?? "typescript",
1582
+ (output) => {
1583
+ response.write(JSON.stringify({ kind: "output", output }) + "\n");
1584
+ },
1585
+ requestInput
1586
+ );
1587
+ response.write(JSON.stringify({ kind: "result", result }) + "\n");
1588
+ cleanup();
1589
+ response.end();
1590
+ return;
1591
+ }
1592
+
1593
+ const result = await session.execute(
1594
+ body.code ?? "",
1595
+ body.cellId,
1596
+ body.env ?? {},
1597
+ body.language ?? "typescript",
1598
+ null,
1599
+ async () => ""
1600
+ );
1601
+
1602
+ json(response, 200, result);
1603
+ return;
1604
+ }
1605
+
1606
+ if (request.method === "POST" && requestUrl.pathname === "/api/execute/input") {
1607
+ const body = await readRequestBody(request);
1608
+ const runId = String(body.runId ?? "");
1609
+ const value = body.value ?? "";
1610
+ const queue = pendingInputResolvers.get(runId);
1611
+ if (!queue || queue.length === 0) {
1612
+ json(response, 404, { ok: false, error: "No pending input for this run" });
1613
+ return;
1614
+ }
1615
+ const resolver = queue.shift();
1616
+ resolver(String(value));
1617
+ json(response, 200, { ok: true });
1618
+ return;
1619
+ }
1620
+
1621
+ if (request.method === "POST" && requestUrl.pathname === "/api/execute/cancel") {
1622
+ const body = await readRequestBody(request);
1623
+ const notebookPath = resolveNotebookPath(workspaceRoot, notebooksDir, body.path);
1624
+ const session = getSession(notebookPath);
1625
+ session?.cancelExecution();
1626
+ json(response, 200, { ok: true });
1627
+ return;
1628
+ }
1629
+
1630
+ if (request.method === "POST" && requestUrl.pathname === "/api/ai/assist") {
1631
+ const body = await readRequestBody(request);
1632
+ const notebookPath = resolveNotebookPath(workspaceRoot, notebooksDir, body.path);
1633
+ const notebook = await ensureNotebook(notebookPath);
1634
+ const env = body.env && typeof body.env === "object" ? body.env : notebook.metadata?.env ?? {};
1635
+ const aiConfig = resolveAiConfig(env, notebook.metadata?.ai, body.ai ?? {});
1636
+ const instruction = String(body.instruction ?? "").trim() || "Explain this code and suggest an improved version.";
1637
+ const source = String(body.source ?? "");
1638
+ const responseText = await requestAiCompletion(aiConfig, [
1639
+ {
1640
+ role: "system",
1641
+ content:
1642
+ "You are a JavaScript and TypeScript notebook assistant. Be concise, preserve intent, and return actionable guidance."
1643
+ },
1644
+ {
1645
+ role: "user",
1646
+ content: `Instruction:\n${instruction}\n\nSource:\n${source}`
1647
+ }
1648
+ ]);
1649
+
1650
+ json(response, 200, { text: responseText });
1651
+ return;
1652
+ }
1653
+
1654
+ if (request.method === "POST" && requestUrl.pathname === "/api/ai/chat") {
1655
+ const body = await readRequestBody(request);
1656
+ const notebookPath = resolveNotebookPath(workspaceRoot, notebooksDir, body.path);
1657
+ const notebook = await ensureNotebook(notebookPath);
1658
+ const env = body.env && typeof body.env === "object" ? body.env : notebook.metadata?.env ?? {};
1659
+ const aiConfig = resolveAiConfig(env, notebook.metadata?.ai, { model: body.model, provider: "groq" });
1660
+ const messages = Array.isArray(body.messages)
1661
+ ? body.messages
1662
+ .map((message) => ({
1663
+ role: message?.role === "assistant" ? "assistant" : "user",
1664
+ content: String(message?.content ?? "")
1665
+ }))
1666
+ .filter((message) => message.content.trim())
1667
+ : [];
1668
+ const source = String(body.source ?? "");
1669
+ const cellType = String(body.cellType ?? "code");
1670
+
1671
+ await streamAiCompletion(aiConfig, [
1672
+ {
1673
+ role: "system",
1674
+ content:
1675
+ "You are a concise JavaScript and TypeScript notebook assistant. Use the current cell as context, answer directly, and provide code when useful."
1676
+ },
1677
+ {
1678
+ role: "system",
1679
+ content: `Current cell type: ${cellType}\n\nCurrent cell source:\n${source}`
1680
+ },
1681
+ ...messages
1682
+ ], response);
1683
+ return;
1684
+ }
1685
+
1686
+ if (request.method === "POST" && requestUrl.pathname === "/api/prompt/execute") {
1687
+ const body = await readRequestBody(request);
1688
+ const notebookPath = resolveNotebookPath(workspaceRoot, notebooksDir, body.path);
1689
+ const notebook = await ensureNotebook(notebookPath);
1690
+ const env = body.env && typeof body.env === "object" ? body.env : notebook.metadata?.env ?? {};
1691
+ const aiConfig = resolveAiConfig(env, notebook.metadata?.ai, body.prompt ?? {});
1692
+ const userPrompt = String(body.source ?? "");
1693
+ const systemPrompt = String(body.prompt?.system ?? "");
1694
+ const messages = [];
1695
+
1696
+ if (systemPrompt) {
1697
+ messages.push({ role: "system", content: systemPrompt });
1698
+ }
1699
+
1700
+ messages.push({ role: "user", content: userPrompt });
1701
+ await streamAiCompletion(aiConfig, messages, response);
1702
+ return;
1703
+ }
1704
+
1705
+ if (request.method === "POST" && requestUrl.pathname === "/api/install") {
1706
+ const body = await readRequestBody(request);
1707
+ const notebookPath = resolveNotebookPath(workspaceRoot, notebooksDir, body.path);
1708
+ const packages = Array.isArray(body.packages)
1709
+ ? body.packages.map((value) => String(value).trim()).filter(Boolean)
1710
+ : [];
1711
+
1712
+ if (packages.length === 0) {
1713
+ json(response, 400, { ok: false, error: "No packages provided" });
1714
+ return;
1715
+ }
1716
+
1717
+ const result = await installPackages(notebookPath, packages);
1718
+
1719
+ if (result.ok) {
1720
+ const session = getSession(notebookPath);
1721
+ // Invalidate the bridge cache so the newly installed packages are
1722
+ // immediately importable without a server restart.
1723
+ session.invalidateImportCache();
1724
+ const modules = await session.listInstalledModules();
1725
+ const installedPackages = await session.listDeclaredPackages();
1726
+ json(response, 200, { ...result, modules, installedPackages });
1727
+ return;
1728
+ }
1729
+
1730
+ json(response, 500, result);
1731
+ return;
1732
+ }
1733
+
1734
+ if (request.method === "POST" && requestUrl.pathname === "/api/shell") {
1735
+ const body = await readRequestBody(request);
1736
+ const command = String(body.command ?? "").trim();
1737
+
1738
+ if (!command) {
1739
+ json(response, 400, { ok: false, error: "No command provided" });
1740
+ return;
1741
+ }
1742
+
1743
+ // ── Determine the working directory ──────────────────────────────────
1744
+ // Prefer the client's persisted CWD (set by previous cd/commands).
1745
+ // Fall back to the workspace root — never to the active notebook's
1746
+ // subdirectory, so creating a notebook inside a folder doesn't
1747
+ // silently change where shell commands and npm install run.
1748
+ let cwd;
1749
+ const clientCwd = typeof body.cwd === "string" && body.cwd.trim() ? body.cwd.trim() : null;
1750
+ if (clientCwd && path.isAbsolute(clientCwd) && fs.existsSync(clientCwd)) {
1751
+ cwd = clientCwd;
1752
+ } else {
1753
+ cwd = workspaceRoot;
1754
+ }
1755
+ await fs.promises.mkdir(cwd, { recursive: true });
1756
+
1757
+ // ── Handle `cd` specially — track CWD across commands ────────────────
1758
+ // Each shell invocation is a new process, so `cd` can't persist through
1759
+ // runShellCommand. We intercept it here, resolve the target directory,
1760
+ // and return the new cwd without spawning a shell.
1761
+ const cdMatch = command.match(/^cd(?:\s+(.+?))?$/);
1762
+ if (cdMatch) {
1763
+ const target = (cdMatch[1] ?? "~").trim();
1764
+ let newCwd;
1765
+ if (target === "~" || target === "") {
1766
+ newCwd = process.env.HOME ?? workspaceRoot;
1767
+ } else if (target === "-") {
1768
+ // "cd -" is unsupported in stateless mode; stay in current dir
1769
+ newCwd = cwd;
1770
+ } else if (path.isAbsolute(target)) {
1771
+ newCwd = path.normalize(target);
1772
+ } else {
1773
+ newCwd = path.resolve(cwd, target);
1774
+ }
1775
+
1776
+ if (!fs.existsSync(newCwd) || !fs.statSync(newCwd).isDirectory()) {
1777
+ json(response, 200, { ok: false, stdout: "", stderr: `cd: ${target}: No such file or directory`, cwd });
1778
+ return;
1779
+ }
1780
+
1781
+ json(response, 200, { ok: true, stdout: "", stderr: "", cwd: newCwd });
1782
+ return;
1783
+ }
1784
+
1785
+ // ── Run the command in the resolved CWD ───────────────────────────────
1786
+ const result = await runShellCommand(command, cwd);
1787
+
1788
+ // Detect CWD change from commands that emit "PWD=…" as last line,
1789
+ // or simply keep the same cwd after execution.
1790
+ const resultCwd = result.newCwd ?? cwd;
1791
+
1792
+ const activePath = resolveOpenablePath(workspaceRoot, notebooksDir, body.path, { allowNotebookCreate: true })
1793
+ ?? path.join(cwd, "dummy.ijsnb");
1794
+ const activeKind = getOpenableFileKind(activePath);
1795
+
1796
+ if (activeKind === "notebook") {
1797
+ const session = getSession(activePath);
1798
+
1799
+ // Any shell command can change the installed package set (npm install,
1800
+ // npm uninstall, npm update, yarn add/remove, etc.). Clearing the
1801
+ // import bridge cache ensures the next import() creates a fresh bridge
1802
+ // file with a new URL, so Node.js resolves the module from disk
1803
+ // instead of serving a stale entry from its own ESM module cache.
1804
+ // This makes both install (new package usable) and uninstall (removed
1805
+ // package no longer usable) take effect immediately without a restart.
1806
+ session.invalidateImportCache();
1807
+
1808
+ const modules = await session.listInstalledModules();
1809
+ const installedPackages = await session.listDeclaredPackages();
1810
+ json(response, 200, { ...result, cwd: resultCwd, modules, installedPackages });
1811
+ return;
1812
+ }
1813
+
1814
+ json(response, 200, { ...result, cwd: resultCwd });
1815
+ return;
1816
+ }
1817
+
1818
+ // ── /api/notebooks — list every .ijsnb file in the workspace ──────────
1819
+ if (request.method === "GET" && requestUrl.pathname === "/api/notebooks") {
1820
+ const results = [];
1821
+
1822
+ async function scanDir(dir) {
1823
+ let entries;
1824
+ try { entries = await fs.promises.readdir(dir, { withFileTypes: true }); }
1825
+ catch { return; }
1826
+ for (const entry of entries) {
1827
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
1828
+ const abs = path.join(dir, entry.name);
1829
+ if (entry.isDirectory()) { await scanDir(abs); continue; }
1830
+ if (!entry.name.endsWith(NOTEBOOK_EXTENSION)) continue;
1831
+ try {
1832
+ const stat = await fs.promises.stat(abs);
1833
+ const raw = await fs.promises.readFile(abs, "utf8");
1834
+ const nb = JSON.parse(raw);
1835
+ const codeCells = (nb.cells ?? []).filter(c => c.type === "code");
1836
+ const langs = [...new Set(codeCells.map(c => c.language ?? "typescript"))];
1837
+ results.push({
1838
+ path: abs,
1839
+ relativePath: path.relative(workspaceRoot, abs),
1840
+ appPath: getAppPathFromWorkspacePath(workspaceRoot, notebooksDir, abs),
1841
+ title: nb.metadata?.title ?? path.basename(abs, NOTEBOOK_EXTENSION),
1842
+ cellCount: (nb.cells ?? []).length,
1843
+ language: langs[0] ?? "typescript",
1844
+ updatedAt: nb.metadata?.updatedAt ?? stat.mtime.toISOString(),
1845
+ createdAt: nb.metadata?.createdAt ?? stat.birthtime.toISOString()
1846
+ });
1847
+ } catch { /* skip malformed */ }
1848
+ }
1849
+ }
1850
+
1851
+ await scanDir(workspaceRoot);
1852
+ results.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
1853
+ json(response, 200, { notebooks: results });
1854
+ return;
1855
+ }
1856
+
1857
+ // ── /api/stats — aggregate workspace statistics ──────────────────────
1858
+ if (request.method === "GET" && requestUrl.pathname === "/api/stats") {
1859
+ const allPackages = new Set();
1860
+ let notebookCount = 0;
1861
+ let totalExecutions = 0;
1862
+ let aiTokensUsed = 0;
1863
+ const tokenUsageForCell = (cell) => {
1864
+ const metrics = cell && typeof cell.metrics === "object" ? cell.metrics : null;
1865
+ if (metrics && (Number.isFinite(metrics.aiTokensTotal) || Number.isFinite(metrics.aiTokensOut) || Number.isFinite(metrics.aiTokensIn))) {
1866
+ const total = Number.isFinite(metrics.aiTokensTotal)
1867
+ ? metrics.aiTokensTotal
1868
+ : (Number(metrics.aiTokensIn) || 0) + (Number(metrics.aiTokensOut) || 0);
1869
+ return Math.max(0, total);
1870
+ }
1871
+ if (cell?.type === "prompt" && Array.isArray(cell.outputs)) {
1872
+ return cell.outputs.reduce((sum, out) => {
1873
+ const text = out?.data?.markdown ?? out?.text ?? "";
1874
+ return sum + estimateTokens(text);
1875
+ }, 0);
1876
+ }
1877
+ return 0;
1878
+ };
1879
+
1880
+ async function scanStats(dir) {
1881
+ let entries;
1882
+ try { entries = await fs.promises.readdir(dir, { withFileTypes: true }); }
1883
+ catch { return; }
1884
+ for (const entry of entries) {
1885
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
1886
+ const abs = path.join(dir, entry.name);
1887
+ if (entry.isDirectory()) { await scanStats(abs); continue; }
1888
+ if (!entry.name.endsWith(NOTEBOOK_EXTENSION)) continue;
1889
+ notebookCount++;
1890
+ try {
1891
+ const raw = await fs.promises.readFile(abs, "utf8");
1892
+ const nb = JSON.parse(raw);
1893
+ for (const cell of nb.cells ?? []) {
1894
+ if (typeof cell.executionCount === "number" && cell.executionCount > 0) {
1895
+ totalExecutions += cell.executionCount;
1896
+ }
1897
+ aiTokensUsed += tokenUsageForCell(cell);
1898
+ }
1899
+ const pkgPath = path.join(path.dirname(abs), "package.json");
1900
+ if (fs.existsSync(pkgPath)) {
1901
+ const pkg = JSON.parse(await fs.promises.readFile(pkgPath, "utf8"));
1902
+ for (const dep of Object.keys(pkg.dependencies ?? {})) allPackages.add(dep);
1903
+ }
1904
+ } catch { /* skip malformed */ }
1905
+ }
1906
+ }
1907
+
1908
+ await scanStats(workspaceRoot);
1909
+
1910
+ // Also count root-level package.json packages
1911
+ const rootPkgPath = path.join(workspaceRoot, "package.json");
1912
+ if (fs.existsSync(rootPkgPath)) {
1913
+ try {
1914
+ const pkg = JSON.parse(await fs.promises.readFile(rootPkgPath, "utf8"));
1915
+ for (const dep of Object.keys(pkg.dependencies ?? {})) allPackages.add(dep);
1916
+ } catch { /* skip */ }
1917
+ }
1918
+
1919
+ json(response, 200, {
1920
+ notebookCount,
1921
+ packageCount: allPackages.size,
1922
+ totalExecutions,
1923
+ aiTokensUsed,
1924
+ packages: [...allPackages]
1925
+ });
1926
+ return;
1927
+ }
1928
+
1929
+ // ── /api/notebook/delete — permanently remove a notebook file ────────
1930
+ if (request.method === "POST" && requestUrl.pathname === "/api/notebook/delete") {
1931
+ const body = await readRequestBody(request);
1932
+
1933
+ // If the client sends an absolute path (e.g. from the explorer), use it
1934
+ // directly after a workspace safety check — don't flatten nested paths.
1935
+ let notebookPath;
1936
+ if (body.path && path.isAbsolute(body.path)) {
1937
+ const resolved = body.path.endsWith(NOTEBOOK_EXTENSION) ? body.path : `${body.path}${NOTEBOOK_EXTENSION}`;
1938
+ const rel = path.relative(workspaceRoot, resolved);
1939
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) {
1940
+ json(response, 403, { error: "Path is outside workspace" });
1941
+ return;
1942
+ }
1943
+ notebookPath = resolved;
1944
+ } else {
1945
+ notebookPath = resolveNotebookPath(workspaceRoot, notebooksDir, body.path);
1946
+ }
1947
+
1948
+ if (!fs.existsSync(notebookPath)) {
1949
+ json(response, 404, { error: "Notebook not found" });
1950
+ return;
1951
+ }
1952
+
1953
+ await fs.promises.unlink(notebookPath);
1954
+ json(response, 200, { ok: true });
1955
+ return;
1956
+ }
1957
+
1958
+ // ── /api/file/save — write or create a text workspace file ─────────────
1959
+ if (request.method === "POST" && requestUrl.pathname === "/api/file/save") {
1960
+ const body = await readRequestBody(request);
1961
+ if (!body.path) {
1962
+ json(response, 400, { error: "Missing path" });
1963
+ return;
1964
+ }
1965
+
1966
+ // Resolve path — support both:
1967
+ // • Real absolute filesystem paths (e.g. "/Users/foo/workspace/test.js")
1968
+ // sent by saveCurrentFile() via state.filePreview.path
1969
+ // • URL-style paths (e.g. "/notebooks/data.js")
1970
+ // sent by createNewNotebook() via the appPath variable
1971
+ //
1972
+ // The key distinction: a real absolute path starts with the workspace root.
1973
+ // URL-style paths like "/notebooks/…" also start with "/" but are NOT real
1974
+ // filesystem paths — path.isAbsolute() returns true for both, so we must
1975
+ // check startsWith(workspaceRoot) instead.
1976
+ const rawStr = String(body.path);
1977
+ let candidate;
1978
+ if (path.isAbsolute(rawStr) && rawStr.startsWith(workspaceRoot)) {
1979
+ // Real absolute filesystem path — normalise and use directly.
1980
+ candidate = path.normalize(rawStr);
1981
+ } else {
1982
+ // URL-style path: strip leading slashes and the /notebooks/ prefix,
1983
+ // then resolve relative to the workspace root.
1984
+ const stripped = rawStr.replace(/^\/+/, "").replace(/^notebooks\//, "");
1985
+ candidate = path.resolve(workspaceRoot, stripped);
1986
+ }
1987
+
1988
+ // Security: must remain inside workspace
1989
+ const relToWs = path.relative(workspaceRoot, candidate);
1990
+ if (!relToWs || relToWs.startsWith("..") || path.isAbsolute(relToWs)) {
1991
+ json(response, 403, { error: "Path is outside the workspace" });
1992
+ return;
1993
+ }
1994
+
1995
+ const fileKind = getOpenableFileKind(candidate);
1996
+ if (fileKind !== "text") {
1997
+ json(response, 400, { error: "Only text files (.js, .ts, .md, .txt) can be saved via this endpoint" });
1998
+ return;
1999
+ }
2000
+
2001
+ const ext = path.extname(candidate).toLowerCase();
2002
+ let rawContent = String(body.content ?? "");
2003
+ let formattedContent = null;
2004
+
2005
+ // Auto-format .js and .ts files with Prettier (same settings as notebooks).
2006
+ if (ext === ".js" || ext === ".ts") {
2007
+ try {
2008
+ const parser = ext === ".ts" ? "typescript" : "babel";
2009
+ const plugins = ext === ".ts"
2010
+ ? [prettierPluginTypeScript, prettierPluginEstree]
2011
+ : [prettierPluginBabel, prettierPluginEstree];
2012
+
2013
+ formattedContent = await prettier.format(rawContent, {
2014
+ parser,
2015
+ plugins,
2016
+ printWidth: 100,
2017
+ tabWidth: 2,
2018
+ semi: false,
2019
+ singleQuote: false
2020
+ });
2021
+ } catch {
2022
+ // Formatting failed (e.g. syntax error) — save the raw content as-is
2023
+ formattedContent = null;
2024
+ }
2025
+ }
2026
+
2027
+ const contentToWrite = formattedContent ?? rawContent;
2028
+
2029
+ // Create parent directories if needed (supports new files in sub-folders)
2030
+ await fs.promises.mkdir(path.dirname(candidate), { recursive: true });
2031
+ await fs.promises.writeFile(candidate, contentToWrite, "utf8");
2032
+ json(response, 200, { ok: true, path: candidate, formattedContent });
2033
+ return;
2034
+ }
2035
+
2036
+ // ── /api/folder/delete — recursively remove a workspace directory ────
2037
+ if (request.method === "POST" && requestUrl.pathname === "/api/folder/delete") {
2038
+ const body = await readRequestBody(request);
2039
+ if (!body.path) {
2040
+ json(response, 400, { error: "Missing path" });
2041
+ return;
2042
+ }
2043
+
2044
+ const folderPath = path.isAbsolute(body.path)
2045
+ ? path.normalize(body.path)
2046
+ : path.resolve(workspaceRoot, body.path);
2047
+
2048
+ const relToWorkspace = path.relative(workspaceRoot, folderPath);
2049
+ if (!relToWorkspace || relToWorkspace.startsWith("..") || path.isAbsolute(relToWorkspace)) {
2050
+ json(response, 403, { error: "Path is outside workspace" });
2051
+ return;
2052
+ }
2053
+
2054
+ // Safety: never delete the workspace root itself
2055
+ if (folderPath === workspaceRoot) {
2056
+ json(response, 403, { error: "Cannot delete workspace root" });
2057
+ return;
2058
+ }
2059
+
2060
+ if (!fs.existsSync(folderPath) || !fs.statSync(folderPath).isDirectory()) {
2061
+ json(response, 404, { error: "Folder not found" });
2062
+ return;
2063
+ }
2064
+
2065
+ await fs.promises.rm(folderPath, { recursive: true, force: true });
2066
+ json(response, 200, { ok: true });
2067
+ return;
2068
+ }
2069
+
2070
+ // ── /api/file/delete — permanently remove any workspace file ─────────
2071
+ if (request.method === "POST" && requestUrl.pathname === "/api/file/delete") {
2072
+ const body = await readRequestBody(request);
2073
+ if (!body.path) {
2074
+ json(response, 400, { error: "Missing path" });
2075
+ return;
2076
+ }
2077
+
2078
+ const filePath = path.isAbsolute(body.path)
2079
+ ? path.normalize(body.path)
2080
+ : path.resolve(workspaceRoot, body.path);
2081
+
2082
+ // Security: use path.relative to verify the file is inside the workspace
2083
+ const relToWorkspace = path.relative(workspaceRoot, filePath);
2084
+ if (!relToWorkspace || relToWorkspace.startsWith("..") || path.isAbsolute(relToWorkspace)) {
2085
+ json(response, 403, { error: "Path is outside workspace" });
2086
+ return;
2087
+ }
2088
+
2089
+ // Block notebook files from this endpoint — use /api/notebook/delete instead
2090
+ if (filePath.endsWith(NOTEBOOK_EXTENSION)) {
2091
+ json(response, 400, { error: "Use /api/notebook/delete for notebooks" });
2092
+ return;
2093
+ }
2094
+
2095
+ if (!fs.existsSync(filePath)) {
2096
+ json(response, 404, { error: "File not found" });
2097
+ return;
2098
+ }
2099
+
2100
+ await fs.promises.unlink(filePath);
2101
+ json(response, 200, { ok: true });
2102
+ return;
2103
+ }
2104
+
2105
+ // ── /api/stats/reset-executions — zero out all cell execution counts ──
2106
+ if (request.method === "POST" && requestUrl.pathname === "/api/stats/reset-executions") {
2107
+ async function resetExecutionsInDir(dir) {
2108
+ let entries;
2109
+ try { entries = await fs.promises.readdir(dir, { withFileTypes: true }); }
2110
+ catch { return; }
2111
+ for (const entry of entries) {
2112
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
2113
+ const abs = path.join(dir, entry.name);
2114
+ if (entry.isDirectory()) { await resetExecutionsInDir(abs); continue; }
2115
+ if (!entry.name.endsWith(NOTEBOOK_EXTENSION)) continue;
2116
+ try {
2117
+ const raw = await fs.promises.readFile(abs, "utf8");
2118
+ const nb = JSON.parse(raw);
2119
+ let changed = false;
2120
+ for (const cell of nb.cells ?? []) {
2121
+ if (typeof cell.executionCount === "number" && cell.executionCount !== 0) {
2122
+ cell.executionCount = 0;
2123
+ changed = true;
2124
+ }
2125
+ }
2126
+ if (changed) {
2127
+ await fs.promises.writeFile(abs, JSON.stringify(nb, null, 2));
2128
+ }
2129
+ } catch { /* skip malformed */ }
2130
+ }
2131
+ }
2132
+ await resetExecutionsInDir(workspaceRoot);
2133
+ json(response, 200, { ok: true });
2134
+ return;
2135
+ }
2136
+
2137
+ // ── /api/stats/reset-ai-tokens — zero out stored AI token usage ───────
2138
+ if (request.method === "POST" && requestUrl.pathname === "/api/stats/reset-ai-tokens") {
2139
+ async function resetTokensInDir(dir) {
2140
+ let entries;
2141
+ try { entries = await fs.promises.readdir(dir, { withFileTypes: true }); }
2142
+ catch { return; }
2143
+ for (const entry of entries) {
2144
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
2145
+ const abs = path.join(dir, entry.name);
2146
+ if (entry.isDirectory()) { await resetTokensInDir(abs); continue; }
2147
+ if (!entry.name.endsWith(NOTEBOOK_EXTENSION)) continue;
2148
+ try {
2149
+ const raw = await fs.promises.readFile(abs, "utf8");
2150
+ const nb = JSON.parse(raw);
2151
+ let changed = false;
2152
+ for (const cell of nb.cells ?? []) {
2153
+ if (cell.type !== "prompt") continue;
2154
+ const metrics = cell.metrics && typeof cell.metrics === "object" ? cell.metrics : {};
2155
+ const nextMetrics = {
2156
+ aiTokensIn: 0,
2157
+ aiTokensOut: 0,
2158
+ aiTokensTotal: 0,
2159
+ aiTokensUpdatedAt: new Date().toISOString()
2160
+ };
2161
+ const merged = { ...metrics, ...nextMetrics };
2162
+ if (JSON.stringify(merged) !== JSON.stringify(metrics)) {
2163
+ cell.metrics = merged;
2164
+ changed = true;
2165
+ } else if (!cell.metrics) {
2166
+ cell.metrics = nextMetrics;
2167
+ changed = true;
2168
+ }
2169
+ }
2170
+ if (changed) {
2171
+ await fs.promises.writeFile(abs, JSON.stringify(nb, null, 2));
2172
+ }
2173
+ } catch { /* skip malformed */ }
2174
+ }
2175
+ }
2176
+ await resetTokensInDir(workspaceRoot);
2177
+ json(response, 200, { ok: true });
2178
+ return;
2179
+ }
2180
+
2181
+ if (request.method === "GET" && requestUrl.pathname.startsWith("/vendor/monaco/")) {
2182
+ const vendorPath = requestUrl.pathname.replace("/vendor/monaco", "");
2183
+ await serveFile(monacoDir, vendorPath, response);
2184
+ return;
2185
+ }
2186
+
2187
+ if (request.method === "GET") {
2188
+ await serveStaticAsset(requestUrl.pathname, response);
2189
+ return;
2190
+ }
2191
+
2192
+ notFound(response);
2193
+ } catch (error) {
2194
+ json(response, 500, {
2195
+ error: error?.message ?? String(error),
2196
+ stack: error?.stack ?? null
2197
+ });
2198
+ }
2199
+ });
2200
+
2201
+ server.timeout = 0;
2202
+ server.requestTimeout = 0;
2203
+ server.keepAliveTimeout = 0;
2204
+ server.headersTimeout = 0;
2205
+
2206
+ return {
2207
+ server,
2208
+ workspaceRoot,
2209
+ notebooksDir
2210
+ };
2211
+ }
2212
+
2213
+ export async function startServer(options = {}) {
2214
+ const port = Number(options.port ?? process.env.PORT ?? 3113);
2215
+ const host = String(options.host ?? process.env.HOST ?? "127.0.0.1");
2216
+ const { server, workspaceRoot } = await createServer(options);
2217
+
2218
+ await new Promise((resolve, reject) => {
2219
+ server.once("error", reject);
2220
+ server.listen(port, host, () => {
2221
+ server.off("error", reject);
2222
+ resolve();
2223
+ });
2224
+ });
2225
+
2226
+ return {
2227
+ server,
2228
+ port,
2229
+ host,
2230
+ workspaceRoot
2231
+ };
2232
+ }