@lemonriver/core 0.1.1 → 0.2.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.
- package/dist/{build-f-fM2ZY4.js → build-Df_xwIpg.js} +1 -1
- package/dist/cli/bin.js +4 -4
- package/dist/{config-DO45GU3e.js → config-BjWCLoMQ.js} +2343 -363
- package/dist/{config-B6JaKJM4.d.ts → config-rMGCkz6i.d.ts} +6 -2
- package/dist/{dev-CqS2xpgE.js → dev-Ctsx9IeO.js} +2 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.js +4 -3
- package/dist/locale/index.d.ts +5 -2
- package/dist/locale/index.js +2 -2
- package/dist/{preview-BihgMlEl.js → preview-CJgcqsPk.js} +1 -1
- package/dist/{types-BjW4glip.d.ts → types-8VUyA2bu.d.ts} +133 -5
- package/dist/{update-C_R0ycVC.js → update-BKOA3Z6o.js} +153 -21
- package/dist/vite/index.d.ts +1 -1
- package/dist/vite/index.js +1 -1
- package/dist/{zh-tw-Ci0jK8eg.js → zh-tw-CeLrYwRE.js} +1178 -25
- package/package.json +13 -6
- package/skills/apply-comments/SKILL.md +10 -6
- package/skills/create-slide/SKILL.md +2 -0
- package/skills/current-slide/SKILL.md +1 -0
- package/skills/slide-authoring/SKILL.md +31 -1
- package/src/agent/prompt.ts +141 -0
- package/src/agent/provider.ts +123 -0
- package/src/agent/types.ts +151 -0
- package/src/app/app.tsx +2 -0
- package/src/app/assets/open-slide.svg +39 -0
- package/src/app/components/agent/agent-dock.tsx +35 -0
- package/src/app/components/agent/agent-markdown.tsx +45 -0
- package/src/app/components/agent/agent-panel.tsx +692 -0
- package/src/app/components/agent/agent-presence.tsx +74 -0
- package/src/app/components/agent/composer.tsx +120 -0
- package/src/app/components/agent/connect-view.tsx +184 -0
- package/src/app/components/agent/history-view.tsx +135 -0
- package/src/app/components/agent/provider-logo.tsx +34 -0
- package/src/app/components/agent/transcript.tsx +609 -0
- package/src/app/components/board/board-canvas.tsx +143 -12
- package/src/app/components/board/board.css +51 -0
- package/src/app/components/board/layout.test.ts +53 -1
- package/src/app/components/board/layout.ts +50 -2
- package/src/app/components/board/page-node.tsx +287 -13
- package/src/app/components/board/use-board-cards.ts +210 -0
- package/src/app/components/board/use-page-order.ts +17 -9
- package/src/app/components/command/slide-command-menu.tsx +10 -0
- package/src/app/components/icon-switcher-indicator.tsx +1 -1
- package/src/app/components/inspector/comment-layer.tsx +1011 -0
- package/src/app/components/inspector/image-crop-dialog.tsx +1 -5
- package/src/app/components/inspector/inspect-overlay.tsx +2 -39
- package/src/app/components/inspector/inspector-panel.tsx +10 -93
- package/src/app/components/inspector/inspector-provider.tsx +121 -47
- package/src/app/components/panel/editor-sidebar.tsx +85 -16
- package/src/app/components/panel/panel-shell.tsx +2 -2
- package/src/app/components/panel/resize-handle.tsx +129 -0
- package/src/app/components/player.tsx +0 -1
- package/src/app/components/preview-step-host.tsx +16 -0
- package/src/app/components/sidebar/sidebar.tsx +2 -2
- package/src/app/components/style-panel/style-panel.tsx +3 -2
- package/src/app/components/thumbnail-rail.tsx +76 -1
- package/src/app/favicon.ico +0 -0
- package/src/app/favicon.svg +39 -0
- package/src/app/index.html +2 -1
- package/src/app/lib/agent/use-agent.ts +543 -0
- package/src/app/lib/inspector/freeze-motion.ts +61 -0
- package/src/app/lib/inspector/use-comments.ts +32 -3
- package/src/app/lib/locale-store.ts +6 -1
- package/src/app/lib/print-ready.ts +9 -6
- package/src/app/lib/use-auto-grow.ts +15 -0
- package/src/app/lib/utils.ts +4 -0
- package/src/app/routes/board.tsx +48 -11
- package/src/app/routes/home.tsx +43 -7
- package/src/app/routes/presenter.tsx +1 -16
- package/src/app/routes/preview.tsx +169 -0
- package/src/app/routes/slide.tsx +221 -173
- package/src/app/styles.css +234 -80
- package/src/editing/placeholder-id.ts +1 -0
- package/src/locale/en.ts +127 -5
- package/src/locale/es.ts +692 -0
- package/src/locale/index.ts +1 -0
- package/src/locale/ja.ts +120 -6
- package/src/locale/types.ts +132 -5
- package/src/locale/zh-cn.ts +117 -5
- package/src/locale/zh-tw.ts +117 -5
- package/src/app/assets/open-slide.png +0 -0
- package/src/app/components/inspector/comment-widget.tsx +0 -119
|
@@ -1,18 +1,1986 @@
|
|
|
1
1
|
import { n as defaultDesign } from "./design-Cpqx7SJs.js";
|
|
2
|
-
import { A as
|
|
2
|
+
import { A as renameSlideDir, B as validateMutationRequest, C as duplicatePageInDefaultExportInSource, D as readMetaTitleInSource, E as insertNotesElementInSource, F as slugifyTitle, H as CONFIG_FILES, I as updateMetaTitleInSource, L as validateSlideName, M as reorderNotesArrayInSource, N as resolveSlideEntry, O as removeNotesElementInSource, P as rmSlideDir, R as declaredFrameworkSpecifier, S as duplicateNotesElementInSource, T as insertBlankPageInDefaultExportInSource, W as isFrameworkSpecifier, _ as writeManifest, a as json, b as SLIDE_ID_RE, c as readSlideSource, d as foldersManifestPath, f as newFolderId, g as validateReorder, h as validateName, i as BodyTooLargeError, j as reorderDefaultExportPagesInSource, k as removePageFromDefaultExportInSource, l as resolveSlideEntryPath, m as validateIcon, n as registerUpdateRoutes, o as makeContext, p as readManifest, r as runCommand, s as readBody, t as detectPackageManager, u as FOLDER_ID_RE, v as shortId, w as duplicateSlideDir, x as createBlankSlideDir, z as isLoopbackRequest } from "./update-BKOA3Z6o.js";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
+
import { existsSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
7
8
|
import fs from "node:fs/promises";
|
|
8
9
|
import { parse } from "@babel/parser";
|
|
9
|
-
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
10
11
|
import tailwindcss from "@tailwindcss/vite";
|
|
11
12
|
import react from "@vitejs/plugin-react";
|
|
12
|
-
import
|
|
13
|
-
import { isJSXElement, isJSXFragment } from "@babel/types";
|
|
13
|
+
import os from "node:os";
|
|
14
14
|
import fg from "fast-glob";
|
|
15
15
|
import { loadConfigFromFile, normalizePath } from "vite";
|
|
16
|
+
import * as t from "@babel/types";
|
|
17
|
+
import { isJSXElement, isJSXFragment } from "@babel/types";
|
|
18
|
+
//#region src/editing/placeholder-id.ts
|
|
19
|
+
const UNTITLED_SLIDE_ID_RE = /^untitled(-\d+)?$/;
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region src/agent/provider.ts
|
|
22
|
+
var ProviderUnavailableError = class extends Error {
|
|
23
|
+
constructor(provider) {
|
|
24
|
+
super(`${provider.name} is not connected: install ${provider.sdkPackage} in this workspace to use it from the agent panel.`);
|
|
25
|
+
this.name = "ProviderUnavailableError";
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
const DETAIL_MAX = 400;
|
|
29
|
+
function clipDetail(value) {
|
|
30
|
+
if (value === void 0 || value === null) return null;
|
|
31
|
+
const text = typeof value === "string" ? value : safeJson(value);
|
|
32
|
+
if (!text) return null;
|
|
33
|
+
const oneLine = text.replace(/\s+/g, " ").trim();
|
|
34
|
+
return oneLine.length > DETAIL_MAX ? `${oneLine.slice(0, 399)}…` : oneLine;
|
|
35
|
+
}
|
|
36
|
+
function safeJson(value) {
|
|
37
|
+
try {
|
|
38
|
+
return JSON.stringify(value);
|
|
39
|
+
} catch {
|
|
40
|
+
return String(value);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function isInsideRoots(cwd, file, roots) {
|
|
44
|
+
if (typeof file !== "string" || !file) return false;
|
|
45
|
+
const resolved = path.resolve(cwd, file);
|
|
46
|
+
return roots.some((root) => {
|
|
47
|
+
const rel = path.relative(root, resolved);
|
|
48
|
+
return rel === "" || !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function relativeToCwd(cwd, file) {
|
|
52
|
+
if (typeof file !== "string" || !file) return null;
|
|
53
|
+
const normalizedCwd = cwd.replace(/[\\/]+$/, "");
|
|
54
|
+
if (file.startsWith(normalizedCwd)) return file.slice(normalizedCwd.length).replace(/^[\\/]+/, "") || ".";
|
|
55
|
+
return file;
|
|
56
|
+
}
|
|
57
|
+
function parseQuestions(input) {
|
|
58
|
+
const raw = Array.isArray(input.questions) ? input.questions : [];
|
|
59
|
+
const questions = [];
|
|
60
|
+
for (const q of raw) {
|
|
61
|
+
if (!q || typeof q !== "object") continue;
|
|
62
|
+
const r = q;
|
|
63
|
+
const options = Array.isArray(r.options) ? r.options.map((o) => {
|
|
64
|
+
if (!o || typeof o !== "object") return null;
|
|
65
|
+
const opt = o;
|
|
66
|
+
if (typeof opt.label !== "string") return null;
|
|
67
|
+
return {
|
|
68
|
+
label: opt.label,
|
|
69
|
+
description: typeof opt.description === "string" ? opt.description : void 0
|
|
70
|
+
};
|
|
71
|
+
}).filter((o) => o !== null) : [];
|
|
72
|
+
if (typeof r.question !== "string") continue;
|
|
73
|
+
questions.push({
|
|
74
|
+
header: typeof r.header === "string" ? r.header : "",
|
|
75
|
+
question: r.question,
|
|
76
|
+
options,
|
|
77
|
+
multiSelect: r.multiSelect === true
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return questions;
|
|
81
|
+
}
|
|
82
|
+
//#endregion
|
|
83
|
+
//#region src/agent/prompt.ts
|
|
84
|
+
const PAGE_BY_PAGE = "Write a new deck page by page so the user can follow along on the canvas: first replace the file with the imports, meta (keeping its existing createdAt), design and the cover page only, then add each remaining page with its own separate edit, keeping the file compiling after every write.";
|
|
85
|
+
const DECK_SLUG = "This deck's folder still has a placeholder name. Never rename or move the folder yourself: once the deck has content, end your final reply with a slug for it on a line of its own as <deck-slug>short-kebab-case-english-slug</deck-slug>, and LemonRiver renames the folder from it.";
|
|
86
|
+
function systemPromptAppend(askInstruction) {
|
|
87
|
+
return [
|
|
88
|
+
"You are running inside the LemonRiver dev server, driven from its in-app agent panel in the browser rather than a terminal.",
|
|
89
|
+
"The workspace is a LemonRiver project: decks live under slides/<id>/index.tsx and the dev server hot-reloads every edit, so the user sees changes immediately.",
|
|
90
|
+
"Use the project skills: create-slide to draft a new deck, apply-comments to resolve @slide-comment markers left by the inspector, create-theme for themes, and slide-authoring as the technical reference before writing a page.",
|
|
91
|
+
PAGE_BY_PAGE,
|
|
92
|
+
"Keep replies short and concrete: the panel is narrow. Prefer doing the work over describing it, and finish with a one-line summary of what changed.",
|
|
93
|
+
askInstruction
|
|
94
|
+
].join(" ");
|
|
95
|
+
}
|
|
96
|
+
function buildTurnPrompt(text, context) {
|
|
97
|
+
const lines = contextLines(context);
|
|
98
|
+
if (lines.length === 0) return text;
|
|
99
|
+
return `${text}\n\n<open-slide-context>\n${lines.join("\n")}\n</open-slide-context>`;
|
|
100
|
+
}
|
|
101
|
+
const escapeTag = (s) => s.replace(/</g, "<");
|
|
102
|
+
function contextLines(context) {
|
|
103
|
+
if (!context?.slideId) return [];
|
|
104
|
+
const file = `slides/${context.slideId}/index.tsx`;
|
|
105
|
+
const lines = [];
|
|
106
|
+
if (context.newDeck) {
|
|
107
|
+
lines.push(`${file} is a blank deck created for this request. Draft the deck the user describes into it with the create-slide skill: replace the placeholder page, keep the slide id, and do not create another folder.`, "Ask the create-slide scoping questions in a single round, only for what the request leaves unclear; skip any it already answers.", PAGE_BY_PAGE, DECK_SLUG);
|
|
108
|
+
return lines;
|
|
109
|
+
}
|
|
110
|
+
lines.push(`The user is viewing slide "${context.slideId}": ${file}.`);
|
|
111
|
+
if (UNTITLED_SLIDE_ID_RE.test(context.slideId)) lines.push(DECK_SLUG);
|
|
112
|
+
if (context.slideTitle) lines.push(`<slide-title>${escapeTag(context.slideTitle)}</slide-title>`);
|
|
113
|
+
if (context.pageNumber && context.totalPages) lines.push(`They are on page ${context.pageNumber} of ${context.totalPages} (0-based index ${context.pageNumber - 1} in the default export array).`);
|
|
114
|
+
if (context.selection) {
|
|
115
|
+
const { line, column, tagName, text } = context.selection;
|
|
116
|
+
lines.push(`They have selected a <${escapeTag(tagName)}> element at ${file}:${line}:${column}.${text ? " Its text is in <selected-text>." : ""} "This element" refers to it.`);
|
|
117
|
+
if (text) lines.push(`<selected-text>${escapeTag(text)}</selected-text>`);
|
|
118
|
+
}
|
|
119
|
+
if (context.pendingComments) lines.push(`There are ${context.pendingComments} pending @slide-comment markers in this slide's source.`);
|
|
120
|
+
return lines;
|
|
121
|
+
}
|
|
122
|
+
const DECK_SLUG_RE = /\s*<deck-slug>\s*([^<]*?)\s*<\/deck-slug>\s*/gi;
|
|
123
|
+
function extractDeckSlug(text) {
|
|
124
|
+
let slug = null;
|
|
125
|
+
for (const match of text.matchAll(DECK_SLUG_RE)) slug = match[1] || slug;
|
|
126
|
+
return slug;
|
|
127
|
+
}
|
|
128
|
+
function stripDeckSlug(text) {
|
|
129
|
+
return text.replace(DECK_SLUG_RE, "\n").replace(/\s*<deck-slug>[^<]*$/i, "").trimEnd();
|
|
130
|
+
}
|
|
131
|
+
const QUESTIONS_RE = /<open-slide-questions>([\s\S]*?)<\/open-slide-questions>/i;
|
|
132
|
+
const TEXT_QUESTIONS_INSTRUCTION = "You cannot ask the user anything interactively here, and nothing you print in the middle of a turn can be answered. When you need answers before continuing, stop working and end your turn with a final message holding only an <open-slide-questions> block: a JSON array of at most 4 questions shaped like [{\"header\":\"Pages\",\"question\":\"How many pages?\",\"multiSelect\":false,\"options\":[{\"label\":\"6-10 (Recommended)\",\"description\":\"Standard length\"}]}], each with 2 to 4 options, written in the user's language. The panel shows them as a form and sends the answers back as your next message. Write nothing after the block and do not continue the task in the same turn.";
|
|
133
|
+
function extractQuestionBlock(text) {
|
|
134
|
+
const match = QUESTIONS_RE.exec(text);
|
|
135
|
+
if (!match) return null;
|
|
136
|
+
const body = match[1].trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
|
|
137
|
+
let raw;
|
|
138
|
+
try {
|
|
139
|
+
raw = JSON.parse(body);
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
const questions = parseQuestions({ questions: Array.isArray(raw) ? raw : raw?.questions });
|
|
144
|
+
if (questions.length === 0) return null;
|
|
145
|
+
return {
|
|
146
|
+
text: text.replace(QUESTIONS_RE, "").trim(),
|
|
147
|
+
questions
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function answersPrompt(questions, answers) {
|
|
151
|
+
if (!answers) return "I skipped your questions. Continue the task with your best judgement and sensible defaults.";
|
|
152
|
+
return `My answers:\n${questions.map((q) => `- ${q.question} ${answers[q.question] || "(no answer)"}`).join("\n")}\n\nContinue the task with these answers.`;
|
|
153
|
+
}
|
|
154
|
+
function editedPrompt(original, text) {
|
|
155
|
+
return `I went back and edited an earlier message of mine. Treat everything after it in this conversation, including your replies, as discarded, and respond to the edited version instead. Files keep whatever changes were already made.\n\n<original-message>\n${escapeTag(original)}\n</original-message>\n\n${text}`;
|
|
156
|
+
}
|
|
157
|
+
function rewoundPrompt(text) {
|
|
158
|
+
return `${text}\n\n<open-slide-note>I went back and edited this message. Files keep the changes made after it in the discarded part of this conversation, so read the current files before editing them.</open-slide-note>`;
|
|
159
|
+
}
|
|
160
|
+
//#endregion
|
|
161
|
+
//#region src/agent/detect.ts
|
|
162
|
+
const IS_WINDOWS = process.platform === "win32";
|
|
163
|
+
const VERSION_TIMEOUT_MS = 5e3;
|
|
164
|
+
function findOnPath(command, env = process.env) {
|
|
165
|
+
const dirs = (env.PATH ?? env.Path ?? "").split(path.delimiter).filter(Boolean);
|
|
166
|
+
const exts = IS_WINDOWS ? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";") : [""];
|
|
167
|
+
for (const dir of dirs) for (const ext of exts) {
|
|
168
|
+
const candidate = path.join(dir, command + ext.toLowerCase());
|
|
169
|
+
try {
|
|
170
|
+
if (statSync(candidate, { throwIfNoEntry: false })?.isFile()) return candidate;
|
|
171
|
+
} catch {}
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
function findSpawnableCli(command, env = process.env) {
|
|
176
|
+
const found = findOnPath(command, env);
|
|
177
|
+
if (!found || !IS_WINDOWS) return found;
|
|
178
|
+
return found.toLowerCase().endsWith(".exe") ? found : null;
|
|
179
|
+
}
|
|
180
|
+
const versionCache = /* @__PURE__ */ new Map();
|
|
181
|
+
function commandVersion(file) {
|
|
182
|
+
let cached = versionCache.get(file);
|
|
183
|
+
if (!cached) {
|
|
184
|
+
cached = readVersion(file);
|
|
185
|
+
versionCache.set(file, cached);
|
|
186
|
+
}
|
|
187
|
+
return cached;
|
|
188
|
+
}
|
|
189
|
+
function readVersion(file) {
|
|
190
|
+
return new Promise((resolve) => {
|
|
191
|
+
let out = "";
|
|
192
|
+
let child;
|
|
193
|
+
try {
|
|
194
|
+
child = spawn(file, ["--version"], {
|
|
195
|
+
stdio: [
|
|
196
|
+
"ignore",
|
|
197
|
+
"pipe",
|
|
198
|
+
"pipe"
|
|
199
|
+
],
|
|
200
|
+
shell: IS_WINDOWS,
|
|
201
|
+
windowsHide: true
|
|
202
|
+
});
|
|
203
|
+
} catch {
|
|
204
|
+
resolve(null);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const timer = setTimeout(() => {
|
|
208
|
+
child.kill();
|
|
209
|
+
resolve(null);
|
|
210
|
+
}, VERSION_TIMEOUT_MS);
|
|
211
|
+
child.stdout?.on("data", (c) => {
|
|
212
|
+
out += c.toString("utf8");
|
|
213
|
+
});
|
|
214
|
+
child.stderr?.on("data", (c) => {
|
|
215
|
+
out += c.toString("utf8");
|
|
216
|
+
});
|
|
217
|
+
child.on("error", () => {
|
|
218
|
+
clearTimeout(timer);
|
|
219
|
+
resolve(null);
|
|
220
|
+
});
|
|
221
|
+
child.on("close", () => {
|
|
222
|
+
clearTimeout(timer);
|
|
223
|
+
resolve(parseVersion(out));
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
function parseVersion(output) {
|
|
228
|
+
const m = /(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)/.exec(output);
|
|
229
|
+
return m ? m[1] : null;
|
|
230
|
+
}
|
|
231
|
+
function entryFromPackageJson(pkg) {
|
|
232
|
+
const root = pickExport(pkg.exports);
|
|
233
|
+
if (root) return root;
|
|
234
|
+
return pkg.module ?? pkg.main ?? null;
|
|
235
|
+
}
|
|
236
|
+
function pickExport(exportsField) {
|
|
237
|
+
if (!exportsField) return null;
|
|
238
|
+
if (typeof exportsField === "string") return exportsField;
|
|
239
|
+
if (typeof exportsField !== "object") return null;
|
|
240
|
+
const record = exportsField;
|
|
241
|
+
return pickCondition(Object.keys(record).some((k) => k.startsWith(".")) ? record["."] : exportsField);
|
|
242
|
+
}
|
|
243
|
+
function pickCondition(target) {
|
|
244
|
+
if (!target) return null;
|
|
245
|
+
if (typeof target === "string") return target;
|
|
246
|
+
if (Array.isArray(target)) {
|
|
247
|
+
for (const t of target) {
|
|
248
|
+
const hit = pickCondition(t);
|
|
249
|
+
if (hit) return hit;
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
if (typeof target !== "object") return null;
|
|
254
|
+
const record = target;
|
|
255
|
+
for (const key of [
|
|
256
|
+
"import",
|
|
257
|
+
"node",
|
|
258
|
+
"default",
|
|
259
|
+
"require"
|
|
260
|
+
]) if (key in record) {
|
|
261
|
+
const hit = pickCondition(record[key]);
|
|
262
|
+
if (hit) return hit;
|
|
263
|
+
}
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
function resolveWorkspacePackage(userCwd, name) {
|
|
267
|
+
let dir = path.resolve(userCwd);
|
|
268
|
+
while (true) {
|
|
269
|
+
const pkgJsonPath = path.join(dir, "node_modules", name, "package.json");
|
|
270
|
+
if (existsSync(pkgJsonPath)) {
|
|
271
|
+
let pkg;
|
|
272
|
+
try {
|
|
273
|
+
pkg = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
|
|
274
|
+
} catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
const entry = entryFromPackageJson(pkg);
|
|
278
|
+
if (!entry) return null;
|
|
279
|
+
const pkgDir = path.dirname(pkgJsonPath);
|
|
280
|
+
return {
|
|
281
|
+
dir: pkgDir,
|
|
282
|
+
version: pkg.version ?? "0.0.0",
|
|
283
|
+
entry: path.resolve(pkgDir, entry)
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
const parent = path.dirname(dir);
|
|
287
|
+
if (parent === dir) return null;
|
|
288
|
+
dir = parent;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
async function importWorkspacePackage(userCwd, name) {
|
|
292
|
+
const resolved = resolveWorkspacePackage(userCwd, name);
|
|
293
|
+
if (!resolved) return null;
|
|
294
|
+
return await import(pathToFileURL(resolved.entry).href);
|
|
295
|
+
}
|
|
296
|
+
//#endregion
|
|
297
|
+
//#region src/agent/providers/claude.ts
|
|
298
|
+
const CLAUDE_SDK_PACKAGE = "@anthropic-ai/claude-agent-sdk";
|
|
299
|
+
const CLAUDE_EFFORTS = [
|
|
300
|
+
"low",
|
|
301
|
+
"medium",
|
|
302
|
+
"high",
|
|
303
|
+
"xhigh",
|
|
304
|
+
"max"
|
|
305
|
+
];
|
|
306
|
+
const CLAUDE_MODELS = [
|
|
307
|
+
{
|
|
308
|
+
id: "opus",
|
|
309
|
+
label: "Opus",
|
|
310
|
+
efforts: CLAUDE_EFFORTS
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
id: "sonnet",
|
|
314
|
+
label: "Sonnet",
|
|
315
|
+
efforts: CLAUDE_EFFORTS
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
id: "haiku",
|
|
319
|
+
label: "Haiku",
|
|
320
|
+
efforts: []
|
|
321
|
+
}
|
|
322
|
+
];
|
|
323
|
+
const EDIT_TOOLS = /* @__PURE__ */ new Set([
|
|
324
|
+
"Edit",
|
|
325
|
+
"MultiEdit",
|
|
326
|
+
"Write",
|
|
327
|
+
"NotebookEdit"
|
|
328
|
+
]);
|
|
329
|
+
function claudeEffort(effort) {
|
|
330
|
+
return effort && effort !== "minimal" ? effort : void 0;
|
|
331
|
+
}
|
|
332
|
+
function describeClaudeTool(name, input, cwd) {
|
|
333
|
+
const file = relativeToCwd(cwd, input.file_path ?? input.path ?? input.notebook_path);
|
|
334
|
+
switch (name) {
|
|
335
|
+
case "Read": return {
|
|
336
|
+
title: "Read",
|
|
337
|
+
detail: file
|
|
338
|
+
};
|
|
339
|
+
case "Edit":
|
|
340
|
+
case "MultiEdit":
|
|
341
|
+
case "Write":
|
|
342
|
+
case "NotebookEdit": return {
|
|
343
|
+
title: name === "Write" ? "Write" : "Edit",
|
|
344
|
+
detail: file
|
|
345
|
+
};
|
|
346
|
+
case "Bash": return {
|
|
347
|
+
title: "Run command",
|
|
348
|
+
detail: clipDetail(input.command)
|
|
349
|
+
};
|
|
350
|
+
case "Glob":
|
|
351
|
+
case "Grep": return {
|
|
352
|
+
title: "Search",
|
|
353
|
+
detail: clipDetail(input.pattern)
|
|
354
|
+
};
|
|
355
|
+
case "WebFetch": return {
|
|
356
|
+
title: "Fetch",
|
|
357
|
+
detail: clipDetail(input.url)
|
|
358
|
+
};
|
|
359
|
+
case "WebSearch": return {
|
|
360
|
+
title: "Web search",
|
|
361
|
+
detail: clipDetail(input.query)
|
|
362
|
+
};
|
|
363
|
+
case "Skill": return {
|
|
364
|
+
title: "Skill",
|
|
365
|
+
detail: clipDetail(input.skill ?? input.name)
|
|
366
|
+
};
|
|
367
|
+
case "Task": return {
|
|
368
|
+
title: "Subagent",
|
|
369
|
+
detail: clipDetail(input.description)
|
|
370
|
+
};
|
|
371
|
+
case "AskUserQuestion": return {
|
|
372
|
+
title: "Question",
|
|
373
|
+
detail: null
|
|
374
|
+
};
|
|
375
|
+
case "TodoWrite": return {
|
|
376
|
+
title: "Plan",
|
|
377
|
+
detail: null
|
|
378
|
+
};
|
|
379
|
+
default: return {
|
|
380
|
+
title: name,
|
|
381
|
+
detail: clipDetail(input)
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
function createClaudeMapper(emit, cwd) {
|
|
386
|
+
let counter = 0;
|
|
387
|
+
const nextId = () => `t-${Date.now().toString(36)}-${(counter++).toString(36)}`;
|
|
388
|
+
const textByIndex = /* @__PURE__ */ new Map();
|
|
389
|
+
const openTextIds = [];
|
|
390
|
+
let resultSeen = false;
|
|
391
|
+
const handleBlocks = (blocks) => {
|
|
392
|
+
for (const block of blocks) if (block.type === "text") {
|
|
393
|
+
const { text } = block;
|
|
394
|
+
const id = openTextIds.shift();
|
|
395
|
+
if (id) emit({
|
|
396
|
+
type: "text-end",
|
|
397
|
+
id,
|
|
398
|
+
text
|
|
399
|
+
});
|
|
400
|
+
else if (text.trim()) {
|
|
401
|
+
const fresh = nextId();
|
|
402
|
+
emit({
|
|
403
|
+
type: "text-start",
|
|
404
|
+
id: fresh
|
|
405
|
+
});
|
|
406
|
+
emit({
|
|
407
|
+
type: "text-end",
|
|
408
|
+
id: fresh,
|
|
409
|
+
text
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
} else if (block.type === "tool_use") {
|
|
413
|
+
const { title, detail } = describeClaudeTool(block.name, block.input ?? {}, cwd);
|
|
414
|
+
emit({
|
|
415
|
+
type: "tool-start",
|
|
416
|
+
id: block.id,
|
|
417
|
+
name: block.name,
|
|
418
|
+
title,
|
|
419
|
+
detail
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
textByIndex.clear();
|
|
423
|
+
openTextIds.length = 0;
|
|
424
|
+
};
|
|
425
|
+
return {
|
|
426
|
+
get resultSeen() {
|
|
427
|
+
return resultSeen;
|
|
428
|
+
},
|
|
429
|
+
handle(msg) {
|
|
430
|
+
if (!msg || typeof msg !== "object") return;
|
|
431
|
+
if (msg.type === "system" && msg.subtype === "init") {
|
|
432
|
+
emit({
|
|
433
|
+
type: "session-id",
|
|
434
|
+
id: msg.session_id
|
|
435
|
+
});
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (msg.type === "stream_event") {
|
|
439
|
+
if (msg.parent_tool_use_id) return;
|
|
440
|
+
const ev = msg.event;
|
|
441
|
+
if (ev.type === "content_block_start") {
|
|
442
|
+
if (ev.content_block.type === "text") {
|
|
443
|
+
const id = nextId();
|
|
444
|
+
textByIndex.set(ev.index, id);
|
|
445
|
+
openTextIds.push(id);
|
|
446
|
+
emit({
|
|
447
|
+
type: "text-start",
|
|
448
|
+
id
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
} else if (ev.type === "content_block_delta") {
|
|
452
|
+
const id = textByIndex.get(ev.index);
|
|
453
|
+
if (id && ev.delta.type === "text_delta" && ev.delta.text) emit({
|
|
454
|
+
type: "text-delta",
|
|
455
|
+
id,
|
|
456
|
+
text: ev.delta.text
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (msg.type === "assistant") {
|
|
462
|
+
if (msg.parent_tool_use_id) return;
|
|
463
|
+
handleBlocks(msg.message?.content ?? []);
|
|
464
|
+
if (msg.uuid) emit({
|
|
465
|
+
type: "checkpoint",
|
|
466
|
+
id: msg.uuid
|
|
467
|
+
});
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (msg.type === "user") {
|
|
471
|
+
if (msg.parent_tool_use_id) return;
|
|
472
|
+
if (msg.uuid) emit({
|
|
473
|
+
type: "checkpoint",
|
|
474
|
+
id: msg.uuid
|
|
475
|
+
});
|
|
476
|
+
const content = msg.message?.content;
|
|
477
|
+
if (!Array.isArray(content)) return;
|
|
478
|
+
for (const block of content) if (block.type === "tool_result") emit({
|
|
479
|
+
type: "tool-end",
|
|
480
|
+
id: block.tool_use_id,
|
|
481
|
+
ok: !block.is_error
|
|
482
|
+
});
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if (msg.type === "result") {
|
|
486
|
+
resultSeen = true;
|
|
487
|
+
if (msg.session_id) emit({
|
|
488
|
+
type: "session-id",
|
|
489
|
+
id: msg.session_id
|
|
490
|
+
});
|
|
491
|
+
if (msg.is_error || msg.subtype && msg.subtype !== "success") emit({
|
|
492
|
+
type: "error",
|
|
493
|
+
text: (Array.isArray(msg.errors) && msg.errors.length ? msg.errors.join("\n") : null) ?? (typeof msg.result === "string" && msg.result ? msg.result : null) ?? `Claude Code stopped: ${msg.subtype ?? "error"}`
|
|
494
|
+
});
|
|
495
|
+
emit({
|
|
496
|
+
type: "result",
|
|
497
|
+
durationMs: typeof msg.duration_ms === "number" ? msg.duration_ms : null,
|
|
498
|
+
costUsd: typeof msg.total_cost_usd === "number" ? msg.total_cost_usd : null
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
function createClaudeProvider() {
|
|
505
|
+
return {
|
|
506
|
+
id: "claude",
|
|
507
|
+
name: "Claude Code",
|
|
508
|
+
sdkPackage: CLAUDE_SDK_PACKAGE,
|
|
509
|
+
cliCommand: "claude",
|
|
510
|
+
askInstruction: "Ask the user questions with the AskUserQuestion tool, never as plain text.",
|
|
511
|
+
canRewind: true,
|
|
512
|
+
async models() {
|
|
513
|
+
return CLAUDE_MODELS;
|
|
514
|
+
},
|
|
515
|
+
async run(opts) {
|
|
516
|
+
const sdk = await importWorkspacePackage(opts.cwd, CLAUDE_SDK_PACKAGE);
|
|
517
|
+
if (!sdk || typeof sdk.query !== "function") throw new ProviderUnavailableError(this);
|
|
518
|
+
const mapper = createClaudeMapper(opts.emit, opts.cwd);
|
|
519
|
+
if (opts.signal.aborted) return;
|
|
520
|
+
const abortController = new AbortController();
|
|
521
|
+
opts.signal.addEventListener("abort", () => abortController.abort(), { once: true });
|
|
522
|
+
const canUseTool = async (toolName, input) => {
|
|
523
|
+
if (toolName === "AskUserQuestion") {
|
|
524
|
+
const questions = parseQuestions(input);
|
|
525
|
+
const answers = await opts.askUser(questions);
|
|
526
|
+
if (!answers) return {
|
|
527
|
+
behavior: "deny",
|
|
528
|
+
message: "The user dismissed the question."
|
|
529
|
+
};
|
|
530
|
+
return {
|
|
531
|
+
behavior: "allow",
|
|
532
|
+
updatedInput: {
|
|
533
|
+
...input,
|
|
534
|
+
answers
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
const target = input.file_path ?? input.notebook_path;
|
|
539
|
+
if (EDIT_TOOLS.has(toolName) && isInsideRoots(opts.cwd, target, opts.writableRoots)) return {
|
|
540
|
+
behavior: "allow",
|
|
541
|
+
updatedInput: input
|
|
542
|
+
};
|
|
543
|
+
const { title, detail } = describeClaudeTool(toolName, input, opts.cwd);
|
|
544
|
+
return await opts.requestPermission({
|
|
545
|
+
tool: toolName,
|
|
546
|
+
title,
|
|
547
|
+
detail
|
|
548
|
+
}) ? {
|
|
549
|
+
behavior: "allow",
|
|
550
|
+
updatedInput: input
|
|
551
|
+
} : {
|
|
552
|
+
behavior: "deny",
|
|
553
|
+
message: "The user declined this action in the LemonRiver agent panel."
|
|
554
|
+
};
|
|
555
|
+
};
|
|
556
|
+
const localCli = findSpawnableCli("claude");
|
|
557
|
+
const effort = claudeEffort(opts.effort);
|
|
558
|
+
let stderr = "";
|
|
559
|
+
const stream = sdk.query({
|
|
560
|
+
prompt: opts.prompt,
|
|
561
|
+
options: {
|
|
562
|
+
cwd: opts.cwd,
|
|
563
|
+
permissionMode: "default",
|
|
564
|
+
settingSources: [
|
|
565
|
+
"user",
|
|
566
|
+
"project",
|
|
567
|
+
"local"
|
|
568
|
+
],
|
|
569
|
+
systemPrompt: {
|
|
570
|
+
type: "preset",
|
|
571
|
+
preset: "claude_code",
|
|
572
|
+
append: opts.systemPromptAppend
|
|
573
|
+
},
|
|
574
|
+
includePartialMessages: true,
|
|
575
|
+
abortController,
|
|
576
|
+
canUseTool,
|
|
577
|
+
...opts.resume ? { resume: opts.resume } : {},
|
|
578
|
+
...opts.resume && opts.resumeAt ? { resumeSessionAt: opts.resumeAt } : {},
|
|
579
|
+
...opts.model ? { model: opts.model } : {},
|
|
580
|
+
...effort ? { effort } : {},
|
|
581
|
+
...localCli ? { pathToClaudeCodeExecutable: localCli } : {},
|
|
582
|
+
stderr: (data) => {
|
|
583
|
+
stderr = (stderr + data).slice(-2e3);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
try {
|
|
588
|
+
for await (const message of stream) mapper.handle(message);
|
|
589
|
+
} catch (err) {
|
|
590
|
+
if (opts.signal.aborted || mapper.resultSeen) return;
|
|
591
|
+
const base = err instanceof Error ? err.message : String(err);
|
|
592
|
+
const tail = stderr.trim();
|
|
593
|
+
throw new Error(tail && !base.includes(tail) ? `${base}\n${tail}` : base);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
//#endregion
|
|
599
|
+
//#region src/agent/types.ts
|
|
600
|
+
const AGENT_PROVIDER_IDS = ["claude", "codex"];
|
|
601
|
+
const AGENT_EFFORTS = [
|
|
602
|
+
"minimal",
|
|
603
|
+
"low",
|
|
604
|
+
"medium",
|
|
605
|
+
"high",
|
|
606
|
+
"xhigh",
|
|
607
|
+
"max"
|
|
608
|
+
];
|
|
609
|
+
function isAgentProviderId(value) {
|
|
610
|
+
return typeof value === "string" && AGENT_PROVIDER_IDS.includes(value);
|
|
611
|
+
}
|
|
612
|
+
function isAgentEffort(value) {
|
|
613
|
+
return typeof value === "string" && AGENT_EFFORTS.includes(value);
|
|
614
|
+
}
|
|
615
|
+
function sessionSummary(session) {
|
|
616
|
+
const { providerSessionId: _p, providerCheckpoint: _c, trimmed: _t, items: _i, ...summary } = session;
|
|
617
|
+
return summary;
|
|
618
|
+
}
|
|
619
|
+
//#endregion
|
|
620
|
+
//#region src/agent/providers/codex.ts
|
|
621
|
+
const CODEX_SDK_PACKAGE = "@openai/codex-sdk";
|
|
622
|
+
const CODEX_DEFAULT_MODEL = {
|
|
623
|
+
id: "",
|
|
624
|
+
label: "Default",
|
|
625
|
+
efforts: [
|
|
626
|
+
"low",
|
|
627
|
+
"medium",
|
|
628
|
+
"high"
|
|
629
|
+
]
|
|
630
|
+
};
|
|
631
|
+
function parseCodexModelsCache(raw) {
|
|
632
|
+
const list = raw?.models;
|
|
633
|
+
if (!Array.isArray(list)) return [];
|
|
634
|
+
const models = [];
|
|
635
|
+
for (const entry of list) {
|
|
636
|
+
if (!entry || typeof entry !== "object") continue;
|
|
637
|
+
const m = entry;
|
|
638
|
+
if (typeof m.slug !== "string" || !m.slug) continue;
|
|
639
|
+
if (m.visibility !== void 0 && m.visibility !== "list") continue;
|
|
640
|
+
const efforts = (Array.isArray(m.supported_reasoning_levels) ? m.supported_reasoning_levels : []).map((l) => l && typeof l === "object" ? l.effort : l).filter(isAgentEffort);
|
|
641
|
+
models.push({
|
|
642
|
+
option: {
|
|
643
|
+
id: m.slug,
|
|
644
|
+
label: typeof m.display_name === "string" && m.display_name ? m.display_name : m.slug,
|
|
645
|
+
efforts
|
|
646
|
+
},
|
|
647
|
+
priority: typeof m.priority === "number" ? m.priority : Number.MAX_SAFE_INTEGER
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
return models.sort((a, b) => a.priority - b.priority).map((m) => m.option);
|
|
651
|
+
}
|
|
652
|
+
async function readCodexModels() {
|
|
653
|
+
const home = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
|
654
|
+
try {
|
|
655
|
+
const models = parseCodexModelsCache(JSON.parse(await fs.readFile(path.join(home, "models_cache.json"), "utf8")));
|
|
656
|
+
return models.length > 0 ? models : [CODEX_DEFAULT_MODEL];
|
|
657
|
+
} catch {
|
|
658
|
+
return [CODEX_DEFAULT_MODEL];
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
function createCodexMapper(emit) {
|
|
662
|
+
const started = /* @__PURE__ */ new Set();
|
|
663
|
+
let resultSeen = false;
|
|
664
|
+
const startTool = (item, name, title, detail) => {
|
|
665
|
+
if (started.has(item.id)) return;
|
|
666
|
+
started.add(item.id);
|
|
667
|
+
emit({
|
|
668
|
+
type: "tool-start",
|
|
669
|
+
id: item.id,
|
|
670
|
+
name,
|
|
671
|
+
title,
|
|
672
|
+
detail
|
|
673
|
+
});
|
|
674
|
+
};
|
|
675
|
+
const describe = (item) => {
|
|
676
|
+
switch (item.type) {
|
|
677
|
+
case "command_execution": return {
|
|
678
|
+
name: "command",
|
|
679
|
+
title: "Run command",
|
|
680
|
+
detail: clipDetail(item.command)
|
|
681
|
+
};
|
|
682
|
+
case "file_change": return {
|
|
683
|
+
name: "file_change",
|
|
684
|
+
title: "Edit",
|
|
685
|
+
detail: clipDetail(item.changes.map((c) => c.path).join(", "))
|
|
686
|
+
};
|
|
687
|
+
case "mcp_tool_call": return {
|
|
688
|
+
name: "mcp",
|
|
689
|
+
title: `${item.server}: ${item.tool}`,
|
|
690
|
+
detail: null
|
|
691
|
+
};
|
|
692
|
+
case "web_search": return {
|
|
693
|
+
name: "web_search",
|
|
694
|
+
title: "Web search",
|
|
695
|
+
detail: clipDetail(item.query)
|
|
696
|
+
};
|
|
697
|
+
default: return {
|
|
698
|
+
name: item.type,
|
|
699
|
+
title: item.type,
|
|
700
|
+
detail: null
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
const handleItem = (phase, item) => {
|
|
705
|
+
switch (item.type) {
|
|
706
|
+
case "agent_message": {
|
|
707
|
+
if (phase !== "completed" || !item.text.trim()) return;
|
|
708
|
+
const asked = extractQuestionBlock(item.text);
|
|
709
|
+
const text = asked ? asked.text : item.text;
|
|
710
|
+
if (text) {
|
|
711
|
+
emit({
|
|
712
|
+
type: "text-start",
|
|
713
|
+
id: item.id
|
|
714
|
+
});
|
|
715
|
+
emit({
|
|
716
|
+
type: "text-end",
|
|
717
|
+
id: item.id,
|
|
718
|
+
text
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
if (asked) emit({
|
|
722
|
+
type: "questions",
|
|
723
|
+
questions: asked.questions
|
|
724
|
+
});
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
case "error":
|
|
728
|
+
if (phase === "completed") emit({
|
|
729
|
+
type: "error",
|
|
730
|
+
text: item.message
|
|
731
|
+
});
|
|
732
|
+
return;
|
|
733
|
+
case "command_execution":
|
|
734
|
+
case "file_change":
|
|
735
|
+
case "mcp_tool_call":
|
|
736
|
+
case "web_search": {
|
|
737
|
+
const { name, title, detail } = describe(item);
|
|
738
|
+
startTool(item, name, title, detail);
|
|
739
|
+
if (phase === "completed") {
|
|
740
|
+
const status = "status" in item ? item.status : "completed";
|
|
741
|
+
emit({
|
|
742
|
+
type: "tool-end",
|
|
743
|
+
id: item.id,
|
|
744
|
+
ok: status !== "failed"
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
default: return;
|
|
750
|
+
}
|
|
751
|
+
};
|
|
752
|
+
return {
|
|
753
|
+
get resultSeen() {
|
|
754
|
+
return resultSeen;
|
|
755
|
+
},
|
|
756
|
+
handle(event) {
|
|
757
|
+
if (!event || typeof event !== "object") return;
|
|
758
|
+
switch (event.type) {
|
|
759
|
+
case "thread.started":
|
|
760
|
+
emit({
|
|
761
|
+
type: "session-id",
|
|
762
|
+
id: event.thread_id
|
|
763
|
+
});
|
|
764
|
+
return;
|
|
765
|
+
case "item.started":
|
|
766
|
+
handleItem("started", event.item);
|
|
767
|
+
return;
|
|
768
|
+
case "item.updated":
|
|
769
|
+
handleItem("updated", event.item);
|
|
770
|
+
return;
|
|
771
|
+
case "item.completed":
|
|
772
|
+
handleItem("completed", event.item);
|
|
773
|
+
return;
|
|
774
|
+
case "turn.failed":
|
|
775
|
+
resultSeen = true;
|
|
776
|
+
emit({
|
|
777
|
+
type: "error",
|
|
778
|
+
text: event.error.message
|
|
779
|
+
});
|
|
780
|
+
emit({
|
|
781
|
+
type: "result",
|
|
782
|
+
durationMs: null,
|
|
783
|
+
costUsd: null
|
|
784
|
+
});
|
|
785
|
+
return;
|
|
786
|
+
case "error":
|
|
787
|
+
emit({
|
|
788
|
+
type: "error",
|
|
789
|
+
text: event.message
|
|
790
|
+
});
|
|
791
|
+
return;
|
|
792
|
+
case "turn.completed":
|
|
793
|
+
resultSeen = true;
|
|
794
|
+
emit({
|
|
795
|
+
type: "result",
|
|
796
|
+
durationMs: null,
|
|
797
|
+
costUsd: null
|
|
798
|
+
});
|
|
799
|
+
return;
|
|
800
|
+
default: return;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
function createCodexProvider() {
|
|
806
|
+
return {
|
|
807
|
+
id: "codex",
|
|
808
|
+
name: "Codex",
|
|
809
|
+
sdkPackage: CODEX_SDK_PACKAGE,
|
|
810
|
+
askInstruction: TEXT_QUESTIONS_INSTRUCTION,
|
|
811
|
+
canRewind: false,
|
|
812
|
+
cliCommand: "codex",
|
|
813
|
+
models: readCodexModels,
|
|
814
|
+
async run(opts) {
|
|
815
|
+
const sdk = await importWorkspacePackage(opts.cwd, CODEX_SDK_PACKAGE);
|
|
816
|
+
if (!sdk || typeof sdk.Codex !== "function") throw new ProviderUnavailableError(this);
|
|
817
|
+
const localCli = findSpawnableCli("codex");
|
|
818
|
+
const codex = new sdk.Codex(localCli ? { codexPathOverride: localCli } : {});
|
|
819
|
+
const threadOptions = {
|
|
820
|
+
workingDirectory: opts.cwd,
|
|
821
|
+
skipGitRepoCheck: true,
|
|
822
|
+
sandboxMode: "workspace-write",
|
|
823
|
+
approvalPolicy: "never",
|
|
824
|
+
...opts.model ? { model: opts.model } : {},
|
|
825
|
+
...opts.effort ? { modelReasoningEffort: opts.effort } : {}
|
|
826
|
+
};
|
|
827
|
+
const thread = opts.resume ? codex.resumeThread(opts.resume, threadOptions) : codex.startThread(threadOptions);
|
|
828
|
+
const input = opts.resume ? opts.prompt : `${opts.systemPromptAppend}\n\n${opts.prompt}`;
|
|
829
|
+
const mapper = createCodexMapper(opts.emit);
|
|
830
|
+
const { events } = await thread.runStreamed(input, { signal: opts.signal });
|
|
831
|
+
try {
|
|
832
|
+
for await (const event of events) mapper.handle(event);
|
|
833
|
+
} catch (err) {
|
|
834
|
+
if (opts.signal.aborted || mapper.resultSeen) return;
|
|
835
|
+
throw err;
|
|
836
|
+
}
|
|
837
|
+
if (thread.id) opts.emit({
|
|
838
|
+
type: "session-id",
|
|
839
|
+
id: thread.id
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
//#endregion
|
|
845
|
+
//#region src/agent/providers/index.ts
|
|
846
|
+
function createProviders() {
|
|
847
|
+
return {
|
|
848
|
+
claude: createClaudeProvider(),
|
|
849
|
+
codex: createCodexProvider()
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
//#endregion
|
|
853
|
+
//#region src/agent/store.ts
|
|
854
|
+
const MAX_SESSIONS = 30;
|
|
855
|
+
const MAX_ITEMS_PER_SESSION = 400;
|
|
856
|
+
const SAVE_DEBOUNCE_MS = 150;
|
|
857
|
+
const TITLE_MAX$1 = 60;
|
|
858
|
+
function newId(prefix) {
|
|
859
|
+
return `${prefix}-${randomBytes(6).toString("hex")}`;
|
|
860
|
+
}
|
|
861
|
+
function titleFromPrompt(text) {
|
|
862
|
+
const line = text.split("\n").map((l) => l.trim()).find((l) => l.length > 0);
|
|
863
|
+
if (!line) return "Untitled";
|
|
864
|
+
return line.length > TITLE_MAX$1 ? `${line.slice(0, 59)}…` : line;
|
|
865
|
+
}
|
|
866
|
+
function readPersisted(file) {
|
|
867
|
+
if (!existsSync(file)) return [];
|
|
868
|
+
try {
|
|
869
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
870
|
+
if (parsed?.version !== 1 || !Array.isArray(parsed.sessions)) return [];
|
|
871
|
+
return parsed.sessions.map((s) => ({
|
|
872
|
+
...s,
|
|
873
|
+
status: "idle",
|
|
874
|
+
items: s.items.map(closePending)
|
|
875
|
+
}));
|
|
876
|
+
} catch {
|
|
877
|
+
return [];
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
function closePending(item) {
|
|
881
|
+
if (item.kind === "assistant" && item.streaming) return {
|
|
882
|
+
...item,
|
|
883
|
+
streaming: false
|
|
884
|
+
};
|
|
885
|
+
if (item.kind === "tool" && item.status === "running") return {
|
|
886
|
+
...item,
|
|
887
|
+
status: "error"
|
|
888
|
+
};
|
|
889
|
+
if (item.kind === "question" && item.status === "pending") return {
|
|
890
|
+
...item,
|
|
891
|
+
status: "cancelled"
|
|
892
|
+
};
|
|
893
|
+
if (item.kind === "permission" && item.status === "pending") return {
|
|
894
|
+
...item,
|
|
895
|
+
status: "denied"
|
|
896
|
+
};
|
|
897
|
+
return item;
|
|
898
|
+
}
|
|
899
|
+
function createSessionStore(opts) {
|
|
900
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
901
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
902
|
+
let saveTimer = null;
|
|
903
|
+
let seq = 0;
|
|
904
|
+
const order = /* @__PURE__ */ new Map();
|
|
905
|
+
if (opts.file) for (const s of readPersisted(opts.file)) {
|
|
906
|
+
sessions.set(s.id, s);
|
|
907
|
+
order.set(s.id, seq++);
|
|
908
|
+
}
|
|
909
|
+
const emit = (event) => {
|
|
910
|
+
for (const l of listeners) l(event);
|
|
911
|
+
};
|
|
912
|
+
const scheduleSave = () => {
|
|
913
|
+
if (!opts.file) return;
|
|
914
|
+
if (saveTimer) clearTimeout(saveTimer);
|
|
915
|
+
saveTimer = setTimeout(() => {
|
|
916
|
+
saveTimer = null;
|
|
917
|
+
save();
|
|
918
|
+
}, SAVE_DEBOUNCE_MS);
|
|
919
|
+
};
|
|
920
|
+
const writeSnapshot = async (file) => {
|
|
921
|
+
const body = {
|
|
922
|
+
version: 1,
|
|
923
|
+
sessions: list().flatMap((s) => sessions.get(s.id) ?? [])
|
|
924
|
+
};
|
|
925
|
+
const tmp = `${file}.tmp`;
|
|
926
|
+
try {
|
|
927
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
928
|
+
await fs.writeFile(tmp, JSON.stringify(body), {
|
|
929
|
+
encoding: "utf8",
|
|
930
|
+
mode: 384
|
|
931
|
+
});
|
|
932
|
+
await fs.rename(tmp, file);
|
|
933
|
+
} catch (err) {
|
|
934
|
+
console.error("[lemonriver] failed to save agent sessions:", err);
|
|
935
|
+
await fs.rm(tmp, { force: true }).catch(() => {});
|
|
936
|
+
}
|
|
937
|
+
};
|
|
938
|
+
let saving = Promise.resolve();
|
|
939
|
+
const save = () => {
|
|
940
|
+
const file = opts.file;
|
|
941
|
+
if (!file) return saving;
|
|
942
|
+
saving = saving.then(() => writeSnapshot(file));
|
|
943
|
+
return saving;
|
|
944
|
+
};
|
|
945
|
+
const touch = (session) => {
|
|
946
|
+
session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
947
|
+
order.set(session.id, seq++);
|
|
948
|
+
emit({
|
|
949
|
+
type: "session",
|
|
950
|
+
session: sessionSummary(session)
|
|
951
|
+
});
|
|
952
|
+
scheduleSave();
|
|
953
|
+
};
|
|
954
|
+
const require = (id) => {
|
|
955
|
+
const session = sessions.get(id);
|
|
956
|
+
if (!session) throw new Error(`unknown agent session: ${id}`);
|
|
957
|
+
return session;
|
|
958
|
+
};
|
|
959
|
+
function list() {
|
|
960
|
+
return Array.from(sessions.values()).sort((a, b) => {
|
|
961
|
+
if (a.updatedAt !== b.updatedAt) return a.updatedAt < b.updatedAt ? 1 : -1;
|
|
962
|
+
return (order.get(b.id) ?? 0) - (order.get(a.id) ?? 0);
|
|
963
|
+
}).map(sessionSummary);
|
|
964
|
+
}
|
|
965
|
+
function get(id) {
|
|
966
|
+
return sessions.get(id) ?? null;
|
|
967
|
+
}
|
|
968
|
+
function create(provider, slideId) {
|
|
969
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
970
|
+
const session = {
|
|
971
|
+
id: newId("s"),
|
|
972
|
+
provider,
|
|
973
|
+
title: "New session",
|
|
974
|
+
createdAt: now,
|
|
975
|
+
updatedAt: now,
|
|
976
|
+
status: "idle",
|
|
977
|
+
slideId,
|
|
978
|
+
providerSessionId: null,
|
|
979
|
+
items: []
|
|
980
|
+
};
|
|
981
|
+
sessions.set(session.id, session);
|
|
982
|
+
order.set(session.id, seq++);
|
|
983
|
+
const idle = list().filter((s) => s.status === "idle");
|
|
984
|
+
for (const stale of idle.slice(MAX_SESSIONS)) {
|
|
985
|
+
sessions.delete(stale.id);
|
|
986
|
+
order.delete(stale.id);
|
|
987
|
+
emit({
|
|
988
|
+
type: "session-removed",
|
|
989
|
+
sessionId: stale.id
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
touch(session);
|
|
993
|
+
return session;
|
|
994
|
+
}
|
|
995
|
+
function remove(id) {
|
|
996
|
+
if (!sessions.delete(id)) return false;
|
|
997
|
+
order.delete(id);
|
|
998
|
+
emit({
|
|
999
|
+
type: "session-removed",
|
|
1000
|
+
sessionId: id
|
|
1001
|
+
});
|
|
1002
|
+
scheduleSave();
|
|
1003
|
+
return true;
|
|
1004
|
+
}
|
|
1005
|
+
function patch(id, changes) {
|
|
1006
|
+
const session = require(id);
|
|
1007
|
+
Object.assign(session, changes);
|
|
1008
|
+
touch(session);
|
|
1009
|
+
return session;
|
|
1010
|
+
}
|
|
1011
|
+
function appendItem(sessionId, item) {
|
|
1012
|
+
const session = require(sessionId);
|
|
1013
|
+
session.items.push(item);
|
|
1014
|
+
if (session.items.length > MAX_ITEMS_PER_SESSION) {
|
|
1015
|
+
session.items.splice(0, session.items.length - MAX_ITEMS_PER_SESSION);
|
|
1016
|
+
session.trimmed = true;
|
|
1017
|
+
}
|
|
1018
|
+
emit({
|
|
1019
|
+
type: "item",
|
|
1020
|
+
sessionId,
|
|
1021
|
+
item
|
|
1022
|
+
});
|
|
1023
|
+
touch(session);
|
|
1024
|
+
return item;
|
|
1025
|
+
}
|
|
1026
|
+
function updateItem(sessionId, itemId, kind, changes) {
|
|
1027
|
+
const session = require(sessionId);
|
|
1028
|
+
const index = session.items.findIndex((i) => i.id === itemId && i.kind === kind);
|
|
1029
|
+
if (index === -1) return null;
|
|
1030
|
+
const next = {
|
|
1031
|
+
...session.items[index],
|
|
1032
|
+
...changes
|
|
1033
|
+
};
|
|
1034
|
+
session.items[index] = next;
|
|
1035
|
+
emit({
|
|
1036
|
+
type: "item",
|
|
1037
|
+
sessionId,
|
|
1038
|
+
item: next
|
|
1039
|
+
});
|
|
1040
|
+
touch(session);
|
|
1041
|
+
return next;
|
|
1042
|
+
}
|
|
1043
|
+
function truncateFrom(sessionId, itemId) {
|
|
1044
|
+
const session = require(sessionId);
|
|
1045
|
+
const index = session.items.findIndex((i) => i.id === itemId);
|
|
1046
|
+
if (index === -1) return false;
|
|
1047
|
+
session.items.splice(index);
|
|
1048
|
+
emit({
|
|
1049
|
+
type: "truncate",
|
|
1050
|
+
sessionId,
|
|
1051
|
+
itemId
|
|
1052
|
+
});
|
|
1053
|
+
touch(session);
|
|
1054
|
+
return true;
|
|
1055
|
+
}
|
|
1056
|
+
function appendDelta(sessionId, itemId, text) {
|
|
1057
|
+
if (!text) return;
|
|
1058
|
+
const item = require(sessionId).items.find((i) => i.id === itemId);
|
|
1059
|
+
if (item?.kind !== "assistant") return;
|
|
1060
|
+
item.text += text;
|
|
1061
|
+
emit({
|
|
1062
|
+
type: "delta",
|
|
1063
|
+
sessionId,
|
|
1064
|
+
itemId,
|
|
1065
|
+
text
|
|
1066
|
+
});
|
|
1067
|
+
scheduleSave();
|
|
1068
|
+
}
|
|
1069
|
+
function subscribe(listener) {
|
|
1070
|
+
listeners.add(listener);
|
|
1071
|
+
return () => {
|
|
1072
|
+
listeners.delete(listener);
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
async function flush() {
|
|
1076
|
+
if (saveTimer) {
|
|
1077
|
+
clearTimeout(saveTimer);
|
|
1078
|
+
saveTimer = null;
|
|
1079
|
+
}
|
|
1080
|
+
await save();
|
|
1081
|
+
}
|
|
1082
|
+
return {
|
|
1083
|
+
list,
|
|
1084
|
+
get,
|
|
1085
|
+
create,
|
|
1086
|
+
remove,
|
|
1087
|
+
patch,
|
|
1088
|
+
appendItem,
|
|
1089
|
+
updateItem,
|
|
1090
|
+
truncateFrom,
|
|
1091
|
+
appendDelta,
|
|
1092
|
+
subscribe,
|
|
1093
|
+
flush
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
//#endregion
|
|
1097
|
+
//#region src/agent/runner.ts
|
|
1098
|
+
function cancelPrompt(prompt) {
|
|
1099
|
+
if (prompt.kind === "question") prompt.resolve(null);
|
|
1100
|
+
else prompt.resolve(false);
|
|
1101
|
+
}
|
|
1102
|
+
function createAgentRunner(opts) {
|
|
1103
|
+
const { userCwd, writableRoots, store, providers, onTurnEnd } = opts;
|
|
1104
|
+
const running = /* @__PURE__ */ new Map();
|
|
1105
|
+
const pending = /* @__PURE__ */ new Map();
|
|
1106
|
+
const lastOptions = /* @__PURE__ */ new Map();
|
|
1107
|
+
const now = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
1108
|
+
function isRunning(sessionId) {
|
|
1109
|
+
return running.has(sessionId);
|
|
1110
|
+
}
|
|
1111
|
+
async function send(sessionId, options) {
|
|
1112
|
+
const session = store.get(sessionId);
|
|
1113
|
+
if (!session) throw new Error(`unknown agent session: ${sessionId}`);
|
|
1114
|
+
if (running.has(sessionId)) throw new Error("session is already running");
|
|
1115
|
+
const text = options.text.trim();
|
|
1116
|
+
if (!text) throw new Error("empty prompt");
|
|
1117
|
+
const provider = providers[session.provider];
|
|
1118
|
+
let context = options.context;
|
|
1119
|
+
let promptText = text;
|
|
1120
|
+
let resumeAt = null;
|
|
1121
|
+
if (options.edit) {
|
|
1122
|
+
const index = session.items.findIndex((i) => i.id === options.edit);
|
|
1123
|
+
const target = session.items[index];
|
|
1124
|
+
if (target?.kind !== "user") throw new Error("unknown message");
|
|
1125
|
+
if (!session.trimmed && !session.items.slice(0, index).some((i) => i.kind === "user")) {
|
|
1126
|
+
store.patch(sessionId, {
|
|
1127
|
+
providerSessionId: null,
|
|
1128
|
+
providerCheckpoint: null
|
|
1129
|
+
});
|
|
1130
|
+
if (target.newDeck && context) context = {
|
|
1131
|
+
...context,
|
|
1132
|
+
newDeck: true
|
|
1133
|
+
};
|
|
1134
|
+
} else if (provider.canRewind && target.resumeAt) {
|
|
1135
|
+
resumeAt = target.resumeAt;
|
|
1136
|
+
store.patch(sessionId, { providerCheckpoint: resumeAt });
|
|
1137
|
+
promptText = rewoundPrompt(text);
|
|
1138
|
+
} else promptText = editedPrompt(target.text, text);
|
|
1139
|
+
store.truncateFrom(sessionId, target.id);
|
|
1140
|
+
}
|
|
1141
|
+
const controller = new AbortController();
|
|
1142
|
+
running.set(sessionId, controller);
|
|
1143
|
+
lastOptions.set(sessionId, {
|
|
1144
|
+
...options,
|
|
1145
|
+
context,
|
|
1146
|
+
edit: void 0
|
|
1147
|
+
});
|
|
1148
|
+
if (session.items.length === 0) store.patch(sessionId, { title: titleFromPrompt(text) });
|
|
1149
|
+
if (options.context?.slideId && !session.slideId) store.patch(sessionId, { slideId: options.context.slideId });
|
|
1150
|
+
for (const item of session.items) if (item.kind === "question" && item.status === "pending" && item.id !== options.answering) store.updateItem(sessionId, item.id, "question", { status: "cancelled" });
|
|
1151
|
+
const userItemId = options.answering ?? newId("u");
|
|
1152
|
+
if (!options.answering) store.appendItem(sessionId, {
|
|
1153
|
+
kind: "user",
|
|
1154
|
+
id: userItemId,
|
|
1155
|
+
ts: now(),
|
|
1156
|
+
text,
|
|
1157
|
+
...session.providerCheckpoint ? { resumeAt: session.providerCheckpoint } : {},
|
|
1158
|
+
...context?.newDeck ? { newDeck: true } : {}
|
|
1159
|
+
});
|
|
1160
|
+
store.patch(sessionId, { status: "running" });
|
|
1161
|
+
const openText = /* @__PURE__ */ new Set();
|
|
1162
|
+
const openTools = /* @__PURE__ */ new Set();
|
|
1163
|
+
const startedAt = Date.now();
|
|
1164
|
+
let resultSeen = false;
|
|
1165
|
+
const appendQuestion = (questions) => store.appendItem(sessionId, {
|
|
1166
|
+
kind: "question",
|
|
1167
|
+
id: newId("q"),
|
|
1168
|
+
ts: now(),
|
|
1169
|
+
questions,
|
|
1170
|
+
answers: null,
|
|
1171
|
+
status: "pending"
|
|
1172
|
+
});
|
|
1173
|
+
const emit = (event) => {
|
|
1174
|
+
if (!store.get(sessionId)) return;
|
|
1175
|
+
switch (event.type) {
|
|
1176
|
+
case "session-id":
|
|
1177
|
+
store.patch(sessionId, { providerSessionId: event.id });
|
|
1178
|
+
return;
|
|
1179
|
+
case "checkpoint":
|
|
1180
|
+
store.patch(sessionId, { providerCheckpoint: event.id });
|
|
1181
|
+
return;
|
|
1182
|
+
case "text-start":
|
|
1183
|
+
openText.add(event.id);
|
|
1184
|
+
store.appendItem(sessionId, {
|
|
1185
|
+
kind: "assistant",
|
|
1186
|
+
id: event.id,
|
|
1187
|
+
ts: now(),
|
|
1188
|
+
text: "",
|
|
1189
|
+
streaming: true
|
|
1190
|
+
});
|
|
1191
|
+
return;
|
|
1192
|
+
case "text-delta":
|
|
1193
|
+
if (!openText.has(event.id)) emit({
|
|
1194
|
+
type: "text-start",
|
|
1195
|
+
id: event.id
|
|
1196
|
+
});
|
|
1197
|
+
store.appendDelta(sessionId, event.id, event.text);
|
|
1198
|
+
return;
|
|
1199
|
+
case "text-end": {
|
|
1200
|
+
if (!openText.has(event.id)) {
|
|
1201
|
+
if (!event.text) return;
|
|
1202
|
+
emit({
|
|
1203
|
+
type: "text-start",
|
|
1204
|
+
id: event.id
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
openText.delete(event.id);
|
|
1208
|
+
const changes = event.text !== null ? {
|
|
1209
|
+
streaming: false,
|
|
1210
|
+
text: event.text
|
|
1211
|
+
} : { streaming: false };
|
|
1212
|
+
store.updateItem(sessionId, event.id, "assistant", changes);
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
case "tool-start":
|
|
1216
|
+
openTools.add(event.id);
|
|
1217
|
+
store.appendItem(sessionId, {
|
|
1218
|
+
kind: "tool",
|
|
1219
|
+
id: event.id,
|
|
1220
|
+
ts: now(),
|
|
1221
|
+
name: event.name,
|
|
1222
|
+
title: event.title,
|
|
1223
|
+
detail: event.detail,
|
|
1224
|
+
status: "running"
|
|
1225
|
+
});
|
|
1226
|
+
return;
|
|
1227
|
+
case "tool-end":
|
|
1228
|
+
openTools.delete(event.id);
|
|
1229
|
+
store.updateItem(sessionId, event.id, "tool", { status: event.ok ? "done" : "error" });
|
|
1230
|
+
return;
|
|
1231
|
+
case "error":
|
|
1232
|
+
store.appendItem(sessionId, {
|
|
1233
|
+
kind: "error",
|
|
1234
|
+
id: newId("e"),
|
|
1235
|
+
ts: now(),
|
|
1236
|
+
text: event.text
|
|
1237
|
+
});
|
|
1238
|
+
return;
|
|
1239
|
+
case "questions":
|
|
1240
|
+
appendQuestion(event.questions);
|
|
1241
|
+
return;
|
|
1242
|
+
case "result":
|
|
1243
|
+
resultSeen = true;
|
|
1244
|
+
store.appendItem(sessionId, {
|
|
1245
|
+
kind: "result",
|
|
1246
|
+
id: newId("r"),
|
|
1247
|
+
ts: now(),
|
|
1248
|
+
durationMs: event.durationMs ?? Date.now() - startedAt,
|
|
1249
|
+
costUsd: event.costUsd,
|
|
1250
|
+
aborted: false
|
|
1251
|
+
});
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
};
|
|
1255
|
+
const askUser = (questions) => new Promise((resolve) => {
|
|
1256
|
+
if (controller.signal.aborted) return resolve(null);
|
|
1257
|
+
const item = appendQuestion(questions);
|
|
1258
|
+
pending.set(item.id, {
|
|
1259
|
+
kind: "question",
|
|
1260
|
+
resolve: (answers) => {
|
|
1261
|
+
pending.delete(item.id);
|
|
1262
|
+
store.updateItem(sessionId, item.id, "question", {
|
|
1263
|
+
answers,
|
|
1264
|
+
status: answers ? "answered" : "cancelled"
|
|
1265
|
+
});
|
|
1266
|
+
resolve(answers);
|
|
1267
|
+
}
|
|
1268
|
+
});
|
|
1269
|
+
});
|
|
1270
|
+
const requestPermission = (request) => new Promise((resolve) => {
|
|
1271
|
+
if (controller.signal.aborted) return resolve(false);
|
|
1272
|
+
const item = store.appendItem(sessionId, {
|
|
1273
|
+
kind: "permission",
|
|
1274
|
+
id: newId("p"),
|
|
1275
|
+
ts: now(),
|
|
1276
|
+
tool: request.tool,
|
|
1277
|
+
title: request.title,
|
|
1278
|
+
detail: request.detail,
|
|
1279
|
+
status: options.autoApprove ? "allowed" : "pending"
|
|
1280
|
+
});
|
|
1281
|
+
if (options.autoApprove) return resolve(true);
|
|
1282
|
+
pending.set(item.id, {
|
|
1283
|
+
kind: "permission",
|
|
1284
|
+
resolve: (allow) => {
|
|
1285
|
+
pending.delete(item.id);
|
|
1286
|
+
store.updateItem(sessionId, item.id, "permission", { status: allow ? "allowed" : "denied" });
|
|
1287
|
+
resolve(allow);
|
|
1288
|
+
}
|
|
1289
|
+
});
|
|
1290
|
+
});
|
|
1291
|
+
const finish = () => {
|
|
1292
|
+
running.delete(sessionId);
|
|
1293
|
+
if (!store.get(sessionId)) return;
|
|
1294
|
+
for (const id of openText) store.updateItem(sessionId, id, "assistant", { streaming: false });
|
|
1295
|
+
for (const id of openTools) store.updateItem(sessionId, id, "tool", { status: "error" });
|
|
1296
|
+
if (!resultSeen) store.appendItem(sessionId, {
|
|
1297
|
+
kind: "result",
|
|
1298
|
+
id: newId("r"),
|
|
1299
|
+
ts: now(),
|
|
1300
|
+
durationMs: Date.now() - startedAt,
|
|
1301
|
+
costUsd: null,
|
|
1302
|
+
aborted: controller.signal.aborted
|
|
1303
|
+
});
|
|
1304
|
+
store.patch(sessionId, { status: "idle" });
|
|
1305
|
+
if (onTurnEnd) Promise.resolve().then(() => onTurnEnd({
|
|
1306
|
+
sessionId,
|
|
1307
|
+
userItemId
|
|
1308
|
+
})).catch((err) => {
|
|
1309
|
+
console.error("[lemonriver] agent turn-end handler failed:", err);
|
|
1310
|
+
});
|
|
1311
|
+
};
|
|
1312
|
+
const prompt = buildTurnPrompt(promptText, context);
|
|
1313
|
+
provider.run({
|
|
1314
|
+
cwd: userCwd,
|
|
1315
|
+
writableRoots,
|
|
1316
|
+
prompt,
|
|
1317
|
+
systemPromptAppend: systemPromptAppend(provider.askInstruction),
|
|
1318
|
+
resume: session.providerSessionId,
|
|
1319
|
+
resumeAt,
|
|
1320
|
+
model: options.model ?? null,
|
|
1321
|
+
effort: options.effort ?? null,
|
|
1322
|
+
signal: controller.signal,
|
|
1323
|
+
emit,
|
|
1324
|
+
askUser,
|
|
1325
|
+
requestPermission
|
|
1326
|
+
}).catch((err) => {
|
|
1327
|
+
if (controller.signal.aborted) return;
|
|
1328
|
+
emit({
|
|
1329
|
+
type: "error",
|
|
1330
|
+
text: err instanceof Error ? err.message : String(err)
|
|
1331
|
+
});
|
|
1332
|
+
}).finally(finish);
|
|
1333
|
+
}
|
|
1334
|
+
function stop(sessionId) {
|
|
1335
|
+
const controller = running.get(sessionId);
|
|
1336
|
+
if (!controller) return false;
|
|
1337
|
+
const session = store.get(sessionId);
|
|
1338
|
+
if (session) for (const item of session.items) {
|
|
1339
|
+
const prompt = pending.get(item.id);
|
|
1340
|
+
if (prompt) cancelPrompt(prompt);
|
|
1341
|
+
}
|
|
1342
|
+
controller.abort();
|
|
1343
|
+
return true;
|
|
1344
|
+
}
|
|
1345
|
+
function respond(sessionId, itemId, payload, next = {}) {
|
|
1346
|
+
if (!store.get(sessionId)?.items.some((i) => i.id === itemId)) return false;
|
|
1347
|
+
const prompt = pending.get(itemId);
|
|
1348
|
+
if (!prompt) return answerInNextTurn(sessionId, itemId, payload, next);
|
|
1349
|
+
if ("cancel" in payload) {
|
|
1350
|
+
cancelPrompt(prompt);
|
|
1351
|
+
return true;
|
|
1352
|
+
}
|
|
1353
|
+
if (prompt.kind === "question" && "answers" in payload) {
|
|
1354
|
+
prompt.resolve(payload.answers);
|
|
1355
|
+
return true;
|
|
1356
|
+
}
|
|
1357
|
+
if (prompt.kind === "permission" && "allow" in payload) {
|
|
1358
|
+
prompt.resolve(payload.allow);
|
|
1359
|
+
return true;
|
|
1360
|
+
}
|
|
1361
|
+
return false;
|
|
1362
|
+
}
|
|
1363
|
+
function answerInNextTurn(sessionId, itemId, payload, next) {
|
|
1364
|
+
const item = store.get(sessionId)?.items.find((i) => i.id === itemId);
|
|
1365
|
+
if (item?.kind !== "question" || item.status !== "pending" || running.has(sessionId)) return false;
|
|
1366
|
+
if (!("answers" in payload) && !("cancel" in payload)) return false;
|
|
1367
|
+
const answers = "answers" in payload ? payload.answers : null;
|
|
1368
|
+
store.updateItem(sessionId, itemId, "question", {
|
|
1369
|
+
answers,
|
|
1370
|
+
status: answers ? "answered" : "cancelled"
|
|
1371
|
+
});
|
|
1372
|
+
const previous = {
|
|
1373
|
+
...lastOptions.get(sessionId),
|
|
1374
|
+
...next
|
|
1375
|
+
};
|
|
1376
|
+
const context = previous.context ? {
|
|
1377
|
+
...previous.context,
|
|
1378
|
+
newDeck: void 0
|
|
1379
|
+
} : void 0;
|
|
1380
|
+
send(sessionId, {
|
|
1381
|
+
...previous,
|
|
1382
|
+
context,
|
|
1383
|
+
text: answersPrompt(item.questions, answers),
|
|
1384
|
+
answering: itemId
|
|
1385
|
+
}).catch((err) => {
|
|
1386
|
+
console.error("[lemonriver] agent turn failed:", err);
|
|
1387
|
+
});
|
|
1388
|
+
return true;
|
|
1389
|
+
}
|
|
1390
|
+
function stopAll() {
|
|
1391
|
+
for (const id of Array.from(running.keys())) stop(id);
|
|
1392
|
+
}
|
|
1393
|
+
return {
|
|
1394
|
+
send,
|
|
1395
|
+
stop,
|
|
1396
|
+
respond,
|
|
1397
|
+
isRunning,
|
|
1398
|
+
stopAll
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
//#endregion
|
|
1402
|
+
//#region src/agent/status.ts
|
|
1403
|
+
const INSTALL_TIMEOUT_MS = 6e5;
|
|
1404
|
+
async function providerStatus(userCwd, provider) {
|
|
1405
|
+
const sdk = resolveWorkspacePackage(userCwd, provider.sdkPackage);
|
|
1406
|
+
const cliPath = findOnPath(provider.cliCommand);
|
|
1407
|
+
const [cliVersion, models] = await Promise.all([cliPath ? commandVersion(cliPath) : null, provider.models()]);
|
|
1408
|
+
return {
|
|
1409
|
+
id: provider.id,
|
|
1410
|
+
name: provider.name,
|
|
1411
|
+
sdkPackage: provider.sdkPackage,
|
|
1412
|
+
sdkVersion: sdk?.version ?? null,
|
|
1413
|
+
cliCommand: provider.cliCommand,
|
|
1414
|
+
cliFound: cliPath !== null,
|
|
1415
|
+
cliVersion,
|
|
1416
|
+
ready: sdk !== null,
|
|
1417
|
+
models
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
async function agentStatus(userCwd, providers, packageManager) {
|
|
1421
|
+
return {
|
|
1422
|
+
enabled: true,
|
|
1423
|
+
packageManager,
|
|
1424
|
+
providers: await Promise.all(Object.values(providers).map((provider) => providerStatus(userCwd, provider)))
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1427
|
+
function installCommandFor(packageManager, pkg) {
|
|
1428
|
+
switch (packageManager) {
|
|
1429
|
+
case "pnpm": return {
|
|
1430
|
+
cmd: "pnpm",
|
|
1431
|
+
args: [
|
|
1432
|
+
"add",
|
|
1433
|
+
"-D",
|
|
1434
|
+
pkg
|
|
1435
|
+
]
|
|
1436
|
+
};
|
|
1437
|
+
case "yarn": return {
|
|
1438
|
+
cmd: "yarn",
|
|
1439
|
+
args: [
|
|
1440
|
+
"add",
|
|
1441
|
+
"-D",
|
|
1442
|
+
pkg
|
|
1443
|
+
]
|
|
1444
|
+
};
|
|
1445
|
+
case "bun": return {
|
|
1446
|
+
cmd: "bun",
|
|
1447
|
+
args: [
|
|
1448
|
+
"add",
|
|
1449
|
+
"-d",
|
|
1450
|
+
pkg
|
|
1451
|
+
]
|
|
1452
|
+
};
|
|
1453
|
+
case "npm": return {
|
|
1454
|
+
cmd: "npm",
|
|
1455
|
+
args: [
|
|
1456
|
+
"install",
|
|
1457
|
+
"-D",
|
|
1458
|
+
pkg
|
|
1459
|
+
]
|
|
1460
|
+
};
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
function installSdk(packageManager, pkg, cwd) {
|
|
1464
|
+
return runCommand(installCommandFor(packageManager, pkg), cwd, INSTALL_TIMEOUT_MS);
|
|
1465
|
+
}
|
|
1466
|
+
//#endregion
|
|
1467
|
+
//#region src/vite/recent-writes.ts
|
|
1468
|
+
const recentWrites = /* @__PURE__ */ new Map();
|
|
1469
|
+
/** Record that `file` was just written by us. */
|
|
1470
|
+
function recordWrite(file, now = Date.now()) {
|
|
1471
|
+
recentWrites.set(file, now);
|
|
1472
|
+
}
|
|
1473
|
+
/**
|
|
1474
|
+
* True if `file` was recorded within the recent-write window. Expired entries
|
|
1475
|
+
* are pruned so a stale path can no longer suppress a later genuine edit.
|
|
1476
|
+
*/
|
|
1477
|
+
function hasRecentWrite(file, now = Date.now()) {
|
|
1478
|
+
const ts = recentWrites.get(file);
|
|
1479
|
+
if (ts == null) return false;
|
|
1480
|
+
if (now - ts < 1500) return true;
|
|
1481
|
+
recentWrites.delete(file);
|
|
1482
|
+
return false;
|
|
1483
|
+
}
|
|
1484
|
+
//#endregion
|
|
1485
|
+
//#region src/vite/open-slide-plugin.ts
|
|
1486
|
+
const SLIDES_VMOD = "virtual:open-slide/slides";
|
|
1487
|
+
const CONFIG_VMOD = "virtual:open-slide/config";
|
|
1488
|
+
const FOLDERS_VMOD = "virtual:open-slide/folders";
|
|
1489
|
+
function resolved$1(id) {
|
|
1490
|
+
return `\0${id}`;
|
|
1491
|
+
}
|
|
1492
|
+
async function findSlides(userCwd, slidesDir) {
|
|
1493
|
+
const abs = path.resolve(userCwd, slidesDir);
|
|
1494
|
+
if (!existsSync(abs)) return [];
|
|
1495
|
+
return (await fg("*/index.{tsx,jsx,ts,js}", {
|
|
1496
|
+
cwd: abs,
|
|
1497
|
+
absolute: true,
|
|
1498
|
+
onlyFiles: true
|
|
1499
|
+
})).sort();
|
|
1500
|
+
}
|
|
1501
|
+
function toId(absFile, slidesRoot) {
|
|
1502
|
+
return path.relative(slidesRoot, absFile).split(path.sep)[0];
|
|
1503
|
+
}
|
|
1504
|
+
const META_THEME_RE = /(?:^|[\s,{])theme\s*:\s*['"]([^'"]+)['"]/;
|
|
1505
|
+
const META_CREATED_AT_RE = /(?:^|[\s,{])createdAt\s*:\s*['"]([^'"]+)['"]/;
|
|
1506
|
+
function extractMeta(src) {
|
|
1507
|
+
const empty = {
|
|
1508
|
+
theme: null,
|
|
1509
|
+
createdAt: null
|
|
1510
|
+
};
|
|
1511
|
+
const metaStart = src.search(/export\s+const\s+meta\b/);
|
|
1512
|
+
if (metaStart === -1) return empty;
|
|
1513
|
+
const eqIdx = src.indexOf("=", metaStart);
|
|
1514
|
+
if (eqIdx === -1) return empty;
|
|
1515
|
+
const openBrace = src.indexOf("{", eqIdx);
|
|
1516
|
+
if (openBrace === -1) return empty;
|
|
1517
|
+
let depth = 0;
|
|
1518
|
+
let closeBrace = -1;
|
|
1519
|
+
for (let i = openBrace; i < src.length; i++) {
|
|
1520
|
+
const ch = src[i];
|
|
1521
|
+
if (ch === "{") depth++;
|
|
1522
|
+
else if (ch === "}") {
|
|
1523
|
+
depth--;
|
|
1524
|
+
if (depth === 0) {
|
|
1525
|
+
closeBrace = i;
|
|
1526
|
+
break;
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
if (closeBrace === -1) return empty;
|
|
1531
|
+
const body = src.slice(openBrace + 1, closeBrace);
|
|
1532
|
+
const themeMatch = body.match(META_THEME_RE);
|
|
1533
|
+
const createdAtMatch = body.match(META_CREATED_AT_RE);
|
|
1534
|
+
return {
|
|
1535
|
+
theme: themeMatch ? themeMatch[1] : null,
|
|
1536
|
+
createdAt: createdAtMatch ? createdAtMatch[1] : null
|
|
1537
|
+
};
|
|
1538
|
+
}
|
|
1539
|
+
async function readSlideMeta(abs) {
|
|
1540
|
+
try {
|
|
1541
|
+
return extractMeta(await fs.readFile(abs, "utf8"));
|
|
1542
|
+
} catch {
|
|
1543
|
+
return {
|
|
1544
|
+
theme: null,
|
|
1545
|
+
createdAt: null
|
|
1546
|
+
};
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
function parseCreatedAtMs(iso) {
|
|
1550
|
+
if (!iso) return null;
|
|
1551
|
+
const ms = Date.parse(iso);
|
|
1552
|
+
return Number.isFinite(ms) ? ms : null;
|
|
1553
|
+
}
|
|
1554
|
+
const warnedInvalidSlideIds = /* @__PURE__ */ new Set();
|
|
1555
|
+
async function generateSlidesModule(files, slidesRoot, isDev) {
|
|
1556
|
+
const scanned = await Promise.all(files.map(async (abs) => {
|
|
1557
|
+
const id = toId(abs, slidesRoot);
|
|
1558
|
+
const importPath = isDev ? `@fs/${normalizePath(abs).replace(/^\/+/, "")}` : abs;
|
|
1559
|
+
const meta = await readSlideMeta(abs);
|
|
1560
|
+
return {
|
|
1561
|
+
id,
|
|
1562
|
+
importPath,
|
|
1563
|
+
theme: meta.theme,
|
|
1564
|
+
createdAt: parseCreatedAtMs(meta.createdAt)
|
|
1565
|
+
};
|
|
1566
|
+
}));
|
|
1567
|
+
const entries = scanned.filter((e) => SLIDE_ID_RE.test(e.id));
|
|
1568
|
+
const ignored = scanned.filter((e) => !SLIDE_ID_RE.test(e.id)).map((e) => e.id);
|
|
1569
|
+
const ids = JSON.stringify(entries.map((e) => e.id).sort());
|
|
1570
|
+
const themesMap = {};
|
|
1571
|
+
const createdAtMap = {};
|
|
1572
|
+
for (const e of entries) {
|
|
1573
|
+
if (e.theme) themesMap[e.id] = e.theme;
|
|
1574
|
+
if (e.createdAt !== null) createdAtMap[e.id] = e.createdAt;
|
|
1575
|
+
}
|
|
1576
|
+
const themesJson = JSON.stringify(themesMap);
|
|
1577
|
+
const createdAtJson = JSON.stringify(createdAtMap);
|
|
1578
|
+
const importTokens = JSON.stringify(Object.fromEntries(entries.map((e) => [e.id, 0])));
|
|
1579
|
+
return {
|
|
1580
|
+
code: `// virtual:open-slide/slides — generated
|
|
1581
|
+
export const slideIds = ${ids};
|
|
1582
|
+
export const slideThemes = ${themesJson};
|
|
1583
|
+
export const slideCreatedAt = ${createdAtJson};
|
|
1584
|
+
${isDev ? `
|
|
1585
|
+
const slideImportTokens = ${importTokens};
|
|
1586
|
+
if (import.meta.hot) {
|
|
1587
|
+
import.meta.hot.on('open-slide:slide-changed', (data) => {
|
|
1588
|
+
const ids = Array.isArray(data?.slideIds) ? data.slideIds : [];
|
|
1589
|
+
const token = Date.now();
|
|
1590
|
+
for (const id of ids) {
|
|
1591
|
+
if (Object.prototype.hasOwnProperty.call(slideImportTokens, id)) slideImportTokens[id] = token;
|
|
1592
|
+
}
|
|
1593
|
+
});
|
|
1594
|
+
}
|
|
1595
|
+
` : ""}
|
|
1596
|
+
|
|
1597
|
+
export async function loadSlide(id) {
|
|
1598
|
+
switch (id) {
|
|
1599
|
+
${entries.map((e) => {
|
|
1600
|
+
const importExpr = isDev ? `import(/* @vite-ignore */ import.meta.env.BASE_URL + ${JSON.stringify(`${e.importPath}?t=`)} + slideImportTokens[${JSON.stringify(e.id)}])` : `import(${JSON.stringify(e.importPath)})`;
|
|
1601
|
+
return ` case ${JSON.stringify(e.id)}: return ${importExpr};`;
|
|
1602
|
+
}).join("\n")}
|
|
1603
|
+
default: throw new Error('Slide not found: ' + id);
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
`,
|
|
1607
|
+
ignored
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1610
|
+
function lemonRiverPlugin(opts) {
|
|
1611
|
+
const { userCwd, config, coreVersion } = opts;
|
|
1612
|
+
const slidesDir = config.slidesDir ?? "slides";
|
|
1613
|
+
const slidesRoot = path.resolve(userCwd, slidesDir);
|
|
1614
|
+
const manifestPath = foldersManifestPath(slidesRoot);
|
|
1615
|
+
let isDev = false;
|
|
1616
|
+
const slideIdForEntry = (p) => {
|
|
1617
|
+
const rel = path.relative(slidesRoot, p);
|
|
1618
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
|
|
1619
|
+
const parts = rel.split(path.sep);
|
|
1620
|
+
if (parts.length !== 2) return null;
|
|
1621
|
+
if (!/^index\.(tsx|jsx|ts|js)$/.test(parts[1])) return null;
|
|
1622
|
+
return parts[0];
|
|
1623
|
+
};
|
|
1624
|
+
let slideChangeTimer = null;
|
|
1625
|
+
const pendingSlideChanges = /* @__PURE__ */ new Set();
|
|
1626
|
+
const queueSlideChanged = (server, id) => {
|
|
1627
|
+
pendingSlideChanges.add(id);
|
|
1628
|
+
if (slideChangeTimer) clearTimeout(slideChangeTimer);
|
|
1629
|
+
slideChangeTimer = setTimeout(() => {
|
|
1630
|
+
slideChangeTimer = null;
|
|
1631
|
+
const mod = server.moduleGraph.getModuleById(resolved$1(SLIDES_VMOD));
|
|
1632
|
+
if (mod) server.moduleGraph.invalidateModule(mod);
|
|
1633
|
+
const slideIds = Array.from(pendingSlideChanges);
|
|
1634
|
+
pendingSlideChanges.clear();
|
|
1635
|
+
server.ws.send({
|
|
1636
|
+
type: "custom",
|
|
1637
|
+
event: "open-slide:slide-changed",
|
|
1638
|
+
data: { slideIds }
|
|
1639
|
+
});
|
|
1640
|
+
}, 100);
|
|
1641
|
+
};
|
|
1642
|
+
return {
|
|
1643
|
+
name: "lemonriver",
|
|
1644
|
+
config(_c, env) {
|
|
1645
|
+
isDev = env.command === "serve";
|
|
1646
|
+
},
|
|
1647
|
+
resolveId(id) {
|
|
1648
|
+
if (id === SLIDES_VMOD) return resolved$1(SLIDES_VMOD);
|
|
1649
|
+
if (id === CONFIG_VMOD) return resolved$1(CONFIG_VMOD);
|
|
1650
|
+
if (id === FOLDERS_VMOD) return resolved$1(FOLDERS_VMOD);
|
|
1651
|
+
return null;
|
|
1652
|
+
},
|
|
1653
|
+
async load(id) {
|
|
1654
|
+
if (id === resolved$1(SLIDES_VMOD)) {
|
|
1655
|
+
const { code, ignored } = await generateSlidesModule(await findSlides(userCwd, slidesDir), slidesRoot, isDev);
|
|
1656
|
+
for (const slideId of ignored) {
|
|
1657
|
+
if (warnedInvalidSlideIds.has(slideId)) continue;
|
|
1658
|
+
warnedInvalidSlideIds.add(slideId);
|
|
1659
|
+
this.warn(`Ignoring slide folder "${slideId}": slide ids must match ${SLIDE_ID_RE} (lowercase/uppercase letters, digits, "-", "_"). Rename the folder under "${slidesDir}/" to a kebab-case id so it appears in the browser and can be moved into folders.`);
|
|
1660
|
+
}
|
|
1661
|
+
return code;
|
|
1662
|
+
}
|
|
1663
|
+
if (id === resolved$1(CONFIG_VMOD)) {
|
|
1664
|
+
const userBuild = config.build ?? {};
|
|
1665
|
+
const buildResolved = isDev ? {
|
|
1666
|
+
showSlideBrowser: true,
|
|
1667
|
+
showSlideUi: true,
|
|
1668
|
+
allowHtmlDownload: true
|
|
1669
|
+
} : {
|
|
1670
|
+
showSlideBrowser: userBuild.showSlideBrowser ?? true,
|
|
1671
|
+
showSlideUi: userBuild.showSlideUi ?? true,
|
|
1672
|
+
allowHtmlDownload: userBuild.allowHtmlDownload ?? true
|
|
1673
|
+
};
|
|
1674
|
+
const resolvedConfig = {
|
|
1675
|
+
...config,
|
|
1676
|
+
build: buildResolved,
|
|
1677
|
+
version: coreVersion
|
|
1678
|
+
};
|
|
1679
|
+
return `export default ${JSON.stringify(resolvedConfig)};\n`;
|
|
1680
|
+
}
|
|
1681
|
+
if (id === resolved$1(FOLDERS_VMOD)) {
|
|
1682
|
+
const manifest = await readManifest(manifestPath);
|
|
1683
|
+
return `export default ${JSON.stringify(manifest)};\n`;
|
|
1684
|
+
}
|
|
1685
|
+
return null;
|
|
1686
|
+
},
|
|
1687
|
+
handleHotUpdate(ctx) {
|
|
1688
|
+
const slideId = slideIdForEntry(ctx.file);
|
|
1689
|
+
if (!slideId) return;
|
|
1690
|
+
if (hasRecentWrite(ctx.file)) return [];
|
|
1691
|
+
queueSlideChanged(ctx.server, slideId);
|
|
1692
|
+
return [];
|
|
1693
|
+
},
|
|
1694
|
+
configureServer(server) {
|
|
1695
|
+
const isSlideEntry = (p) => slideIdForEntry(p) !== null;
|
|
1696
|
+
let reloadTimer = null;
|
|
1697
|
+
const reload = () => {
|
|
1698
|
+
if (reloadTimer) clearTimeout(reloadTimer);
|
|
1699
|
+
reloadTimer = setTimeout(() => {
|
|
1700
|
+
reloadTimer = null;
|
|
1701
|
+
const mod = server.moduleGraph.getModuleById(resolved$1(SLIDES_VMOD));
|
|
1702
|
+
if (mod) server.moduleGraph.invalidateModule(mod);
|
|
1703
|
+
server.ws.send({ type: "full-reload" });
|
|
1704
|
+
}, 150);
|
|
1705
|
+
};
|
|
1706
|
+
if (existsSync(slidesRoot)) server.watcher.add(slidesRoot);
|
|
1707
|
+
server.watcher.on("add", (p) => {
|
|
1708
|
+
if (isSlideEntry(p) && !hasRecentWrite(p)) reload();
|
|
1709
|
+
});
|
|
1710
|
+
server.watcher.on("unlink", (p) => {
|
|
1711
|
+
if (isSlideEntry(p) && !hasRecentWrite(p)) reload();
|
|
1712
|
+
});
|
|
1713
|
+
let foldersTimer = null;
|
|
1714
|
+
const invalidateFolders = () => {
|
|
1715
|
+
if (foldersTimer) clearTimeout(foldersTimer);
|
|
1716
|
+
foldersTimer = setTimeout(() => {
|
|
1717
|
+
foldersTimer = null;
|
|
1718
|
+
const mod = server.moduleGraph.getModuleById(resolved$1(FOLDERS_VMOD));
|
|
1719
|
+
if (mod) server.moduleGraph.invalidateModule(mod);
|
|
1720
|
+
}, 100);
|
|
1721
|
+
};
|
|
1722
|
+
server.watcher.add(manifestPath);
|
|
1723
|
+
server.watcher.on("change", (p) => {
|
|
1724
|
+
if (p === manifestPath) invalidateFolders();
|
|
1725
|
+
});
|
|
1726
|
+
server.watcher.on("add", (p) => {
|
|
1727
|
+
if (p === manifestPath) invalidateFolders();
|
|
1728
|
+
});
|
|
1729
|
+
server.watcher.on("unlink", (p) => {
|
|
1730
|
+
if (p === manifestPath) invalidateFolders();
|
|
1731
|
+
});
|
|
1732
|
+
}
|
|
1733
|
+
};
|
|
1734
|
+
}
|
|
1735
|
+
function invalidateSlidesModule(server) {
|
|
1736
|
+
const mod = server.moduleGraph.getModuleById(resolved$1(SLIDES_VMOD));
|
|
1737
|
+
if (mod) server.moduleGraph.invalidateModule(mod);
|
|
1738
|
+
}
|
|
1739
|
+
async function loadUserConfig(userCwd) {
|
|
1740
|
+
const file = CONFIG_FILES.map((name) => path.join(userCwd, name)).find((p) => existsSync(p));
|
|
1741
|
+
if (!file) return {};
|
|
1742
|
+
return (await loadConfigFromFile({
|
|
1743
|
+
command: "serve",
|
|
1744
|
+
mode: "development"
|
|
1745
|
+
}, file, userCwd, "silent"))?.config ?? {};
|
|
1746
|
+
}
|
|
1747
|
+
//#endregion
|
|
1748
|
+
//#region src/vite/routes/agent.ts
|
|
1749
|
+
function parseContext(raw) {
|
|
1750
|
+
if (!raw || typeof raw !== "object") return void 0;
|
|
1751
|
+
const r = raw;
|
|
1752
|
+
const ctx = {};
|
|
1753
|
+
if (typeof r.slideId === "string" && SLIDE_ID_RE.test(r.slideId)) ctx.slideId = r.slideId;
|
|
1754
|
+
if (typeof r.slideTitle === "string") ctx.slideTitle = r.slideTitle.slice(0, 120);
|
|
1755
|
+
if (typeof r.pageNumber === "number" && Number.isFinite(r.pageNumber)) ctx.pageNumber = Math.max(1, Math.floor(r.pageNumber));
|
|
1756
|
+
if (typeof r.totalPages === "number" && Number.isFinite(r.totalPages)) ctx.totalPages = Math.max(1, Math.floor(r.totalPages));
|
|
1757
|
+
if (typeof r.pendingComments === "number" && Number.isFinite(r.pendingComments)) ctx.pendingComments = Math.max(0, Math.floor(r.pendingComments));
|
|
1758
|
+
if (r.newDeck === true) ctx.newDeck = true;
|
|
1759
|
+
const sel = r.selection;
|
|
1760
|
+
if (sel && typeof sel === "object") {
|
|
1761
|
+
const s = sel;
|
|
1762
|
+
if (typeof s.line === "number" && typeof s.column === "number") ctx.selection = {
|
|
1763
|
+
line: Math.max(1, Math.floor(s.line)),
|
|
1764
|
+
column: Math.max(0, Math.floor(s.column)),
|
|
1765
|
+
tagName: typeof s.tagName === "string" ? s.tagName.slice(0, 32) : "element",
|
|
1766
|
+
text: typeof s.text === "string" ? s.text.slice(0, 120) : ""
|
|
1767
|
+
};
|
|
1768
|
+
}
|
|
1769
|
+
return ctx;
|
|
1770
|
+
}
|
|
1771
|
+
const MODEL_RE = /^[\w.:[\]-]{1,80}$/;
|
|
1772
|
+
function parseModel(raw) {
|
|
1773
|
+
return typeof raw === "string" && MODEL_RE.test(raw) ? raw : null;
|
|
1774
|
+
}
|
|
1775
|
+
async function renamePlaceholderDeck(server, ctx, store, broadcast, turn) {
|
|
1776
|
+
const session = store.get(turn.sessionId);
|
|
1777
|
+
const slideId = session?.slideId;
|
|
1778
|
+
if (!session || !slideId || !UNTITLED_SLIDE_ID_RE.test(slideId)) return;
|
|
1779
|
+
const start = session.items.findIndex((i) => i.id === turn.userItemId);
|
|
1780
|
+
let marked = null;
|
|
1781
|
+
for (const item of session.items.slice(start + 1)) {
|
|
1782
|
+
if (item.kind !== "assistant") continue;
|
|
1783
|
+
const slug = extractDeckSlug(item.text);
|
|
1784
|
+
if (slug === null) continue;
|
|
1785
|
+
marked = slug;
|
|
1786
|
+
store.updateItem(session.id, item.id, "assistant", { text: stripDeckSlug(item.text) });
|
|
1787
|
+
}
|
|
1788
|
+
if (store.list().some((s) => s.slideId === slideId && s.status === "running")) return;
|
|
1789
|
+
const entry = resolveSlideEntry(ctx.slidesRoot, slideId);
|
|
1790
|
+
if (!entry) return;
|
|
1791
|
+
let source;
|
|
1792
|
+
try {
|
|
1793
|
+
source = await fs.readFile(entry, "utf8");
|
|
1794
|
+
} catch {
|
|
1795
|
+
return;
|
|
1796
|
+
}
|
|
1797
|
+
const meta = readMetaTitleInSource(source);
|
|
1798
|
+
if (meta.kind !== "found" || meta.title === "Untitled") return;
|
|
1799
|
+
const desired = slugifyTitle(marked ?? "") || slugifyTitle(meta.title);
|
|
1800
|
+
if (!desired || UNTITLED_SLIDE_ID_RE.test(desired)) return;
|
|
1801
|
+
if (store.list().some((s) => s.slideId === slideId && s.status === "running")) return;
|
|
1802
|
+
recordWrite(entry);
|
|
1803
|
+
const renamed = await renameSlideDir(ctx.slidesRoot, slideId, desired);
|
|
1804
|
+
if (!renamed.ok) return;
|
|
1805
|
+
recordWrite(path.join(renamed.to, "index.tsx"));
|
|
1806
|
+
const manifest = await readManifest(ctx.manifestPath);
|
|
1807
|
+
const folderId = manifest.assignments[slideId];
|
|
1808
|
+
if (folderId) {
|
|
1809
|
+
delete manifest.assignments[slideId];
|
|
1810
|
+
manifest.assignments[renamed.slideId] = folderId;
|
|
1811
|
+
await writeManifest(ctx.manifestPath, manifest);
|
|
1812
|
+
}
|
|
1813
|
+
for (const s of store.list()) if (s.slideId === slideId) store.patch(s.id, { slideId: renamed.slideId });
|
|
1814
|
+
invalidateSlidesModule(server);
|
|
1815
|
+
broadcast({
|
|
1816
|
+
type: "slide-renamed",
|
|
1817
|
+
from: slideId,
|
|
1818
|
+
to: renamed.slideId
|
|
1819
|
+
});
|
|
1820
|
+
}
|
|
1821
|
+
function parseRespond(raw) {
|
|
1822
|
+
if (!raw || typeof raw !== "object") return null;
|
|
1823
|
+
const r = raw;
|
|
1824
|
+
if (r.cancel === true) return { cancel: true };
|
|
1825
|
+
if (typeof r.allow === "boolean") return { allow: r.allow };
|
|
1826
|
+
if (r.answers && typeof r.answers === "object") {
|
|
1827
|
+
const answers = {};
|
|
1828
|
+
for (const [k, v] of Object.entries(r.answers)) if (typeof v === "string") answers[k] = v;
|
|
1829
|
+
return { answers };
|
|
1830
|
+
}
|
|
1831
|
+
return null;
|
|
1832
|
+
}
|
|
1833
|
+
function parseTurnSettings(raw) {
|
|
1834
|
+
const settings = {};
|
|
1835
|
+
const context = parseContext(raw.context);
|
|
1836
|
+
if (context) settings.context = context;
|
|
1837
|
+
if (typeof raw.autoApprove === "boolean") settings.autoApprove = raw.autoApprove;
|
|
1838
|
+
if ("model" in raw) settings.model = parseModel(raw.model);
|
|
1839
|
+
if ("effort" in raw) settings.effort = isAgentEffort(raw.effort) ? raw.effort : null;
|
|
1840
|
+
return settings;
|
|
1841
|
+
}
|
|
1842
|
+
const AGENT_BODY_LIMIT = 1048576;
|
|
1843
|
+
function registerAgentRoutes(server, ctx) {
|
|
1844
|
+
const providers = createProviders();
|
|
1845
|
+
const store = createSessionStore({ file: path.join(ctx.userCwd, "node_modules", ".open-slide", "agent-sessions.json") });
|
|
1846
|
+
const streams = /* @__PURE__ */ new Set();
|
|
1847
|
+
const broadcast = (event) => {
|
|
1848
|
+
const frame = `data: ${JSON.stringify(event)}\n\n`;
|
|
1849
|
+
for (const stream of streams) stream.write(frame);
|
|
1850
|
+
};
|
|
1851
|
+
const heartbeat = setInterval(() => {
|
|
1852
|
+
for (const stream of streams) stream.write(": ping\n\n");
|
|
1853
|
+
}, 25e3);
|
|
1854
|
+
heartbeat.unref();
|
|
1855
|
+
const runner = createAgentRunner({
|
|
1856
|
+
userCwd: ctx.userCwd,
|
|
1857
|
+
writableRoots: [ctx.slidesRoot, ctx.globalAssetsRoot],
|
|
1858
|
+
store,
|
|
1859
|
+
providers,
|
|
1860
|
+
onTurnEnd: (turn) => renamePlaceholderDeck(server, ctx, store, broadcast, turn)
|
|
1861
|
+
});
|
|
1862
|
+
store.subscribe(broadcast);
|
|
1863
|
+
server.httpServer?.on("close", () => {
|
|
1864
|
+
clearInterval(heartbeat);
|
|
1865
|
+
for (const stream of streams) stream.end();
|
|
1866
|
+
runner.stopAll();
|
|
1867
|
+
store.flush();
|
|
1868
|
+
});
|
|
1869
|
+
const installsInFlight = /* @__PURE__ */ new Map();
|
|
1870
|
+
server.middlewares.use("/__agent", async (req, res, next) => {
|
|
1871
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
1872
|
+
const method = req.method ?? "GET";
|
|
1873
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
1874
|
+
const enabled = ctx.agentEnabled && isLoopbackRequest(req);
|
|
1875
|
+
try {
|
|
1876
|
+
if (method === "GET" && url.pathname === "/status") {
|
|
1877
|
+
res.setHeader("cache-control", "no-store");
|
|
1878
|
+
if (!enabled) return json(res, 200, {
|
|
1879
|
+
enabled: false,
|
|
1880
|
+
packageManager: "",
|
|
1881
|
+
providers: []
|
|
1882
|
+
});
|
|
1883
|
+
const pm = await detectPackageManager(ctx.userCwd);
|
|
1884
|
+
return json(res, 200, await agentStatus(ctx.userCwd, providers, pm));
|
|
1885
|
+
}
|
|
1886
|
+
if (!enabled) return json(res, 404, { error: "agent panel disabled" });
|
|
1887
|
+
if (method === "GET" && url.pathname === "/events") {
|
|
1888
|
+
res.writeHead(200, {
|
|
1889
|
+
"content-type": "text/event-stream",
|
|
1890
|
+
"cache-control": "no-store",
|
|
1891
|
+
connection: "keep-alive"
|
|
1892
|
+
});
|
|
1893
|
+
res.write(": connected\n\n");
|
|
1894
|
+
streams.add(res);
|
|
1895
|
+
const drop = () => streams.delete(res);
|
|
1896
|
+
res.on("close", drop);
|
|
1897
|
+
res.on("error", drop);
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
if (method === "POST" && url.pathname === "/install") {
|
|
1901
|
+
const guard = validateMutationRequest(req, { requireJsonBody: true });
|
|
1902
|
+
if (!guard.ok) return json(res, guard.status, { error: guard.error });
|
|
1903
|
+
const body = await readBody(req, AGENT_BODY_LIMIT);
|
|
1904
|
+
if (!isAgentProviderId(body.provider)) return json(res, 400, { error: "invalid provider" });
|
|
1905
|
+
const provider = providers[body.provider];
|
|
1906
|
+
const pm = await detectPackageManager(ctx.userCwd);
|
|
1907
|
+
const pkg = provider.sdkPackage;
|
|
1908
|
+
let install = installsInFlight.get(pkg);
|
|
1909
|
+
if (!install) {
|
|
1910
|
+
install = installSdk(pm, pkg, ctx.userCwd).finally(() => installsInFlight.delete(pkg));
|
|
1911
|
+
installsInFlight.set(pkg, install);
|
|
1912
|
+
}
|
|
1913
|
+
await install;
|
|
1914
|
+
return json(res, 200, { ok: true });
|
|
1915
|
+
}
|
|
1916
|
+
if (method === "GET" && url.pathname === "/sessions") {
|
|
1917
|
+
res.setHeader("cache-control", "no-store");
|
|
1918
|
+
return json(res, 200, { sessions: store.list() });
|
|
1919
|
+
}
|
|
1920
|
+
if (method === "POST" && url.pathname === "/sessions") {
|
|
1921
|
+
const guard = validateMutationRequest(req, { requireJsonBody: true });
|
|
1922
|
+
if (!guard.ok) return json(res, guard.status, { error: guard.error });
|
|
1923
|
+
const body = await readBody(req, AGENT_BODY_LIMIT);
|
|
1924
|
+
if (!isAgentProviderId(body.provider)) return json(res, 400, { error: "invalid provider" });
|
|
1925
|
+
const slideId = typeof body.slideId === "string" && SLIDE_ID_RE.test(body.slideId) ? body.slideId : null;
|
|
1926
|
+
return json(res, 200, store.create(body.provider, slideId));
|
|
1927
|
+
}
|
|
1928
|
+
if (segments[0] === "sessions" && segments[1]) {
|
|
1929
|
+
const id = segments[1];
|
|
1930
|
+
const action = segments[2];
|
|
1931
|
+
const session = store.get(id);
|
|
1932
|
+
if (!session) return json(res, 404, { error: "session not found" });
|
|
1933
|
+
if (method === "GET" && !action) {
|
|
1934
|
+
res.setHeader("cache-control", "no-store");
|
|
1935
|
+
return json(res, 200, session);
|
|
1936
|
+
}
|
|
1937
|
+
if (method === "DELETE" && !action) {
|
|
1938
|
+
const guard = validateMutationRequest(req);
|
|
1939
|
+
if (!guard.ok) return json(res, guard.status, { error: guard.error });
|
|
1940
|
+
runner.stop(id);
|
|
1941
|
+
store.remove(id);
|
|
1942
|
+
return json(res, 200, { ok: true });
|
|
1943
|
+
}
|
|
1944
|
+
if (method === "POST" && action === "messages") {
|
|
1945
|
+
const guard = validateMutationRequest(req, { requireJsonBody: true });
|
|
1946
|
+
if (!guard.ok) return json(res, guard.status, { error: guard.error });
|
|
1947
|
+
const body = await readBody(req, AGENT_BODY_LIMIT);
|
|
1948
|
+
if (typeof body.text !== "string" || !body.text.trim()) return json(res, 400, { error: "missing text" });
|
|
1949
|
+
if (runner.isRunning(id)) return json(res, 409, { error: "session is already running" });
|
|
1950
|
+
if (body.edit !== void 0 && !session.items.some((i) => i.id === body.edit && i.kind === "user")) return json(res, 400, { error: "unknown message" });
|
|
1951
|
+
await runner.send(id, {
|
|
1952
|
+
text: body.text,
|
|
1953
|
+
context: parseContext(body.context),
|
|
1954
|
+
autoApprove: body.autoApprove === true,
|
|
1955
|
+
model: parseModel(body.model),
|
|
1956
|
+
effort: isAgentEffort(body.effort) ? body.effort : null,
|
|
1957
|
+
edit: typeof body.edit === "string" ? body.edit : void 0
|
|
1958
|
+
});
|
|
1959
|
+
return json(res, 202, { ok: true });
|
|
1960
|
+
}
|
|
1961
|
+
if (method === "POST" && action === "stop") {
|
|
1962
|
+
const guard = validateMutationRequest(req);
|
|
1963
|
+
if (!guard.ok) return json(res, guard.status, { error: guard.error });
|
|
1964
|
+
return json(res, 200, { ok: runner.stop(id) });
|
|
1965
|
+
}
|
|
1966
|
+
if (method === "POST" && action === "respond") {
|
|
1967
|
+
const guard = validateMutationRequest(req, { requireJsonBody: true });
|
|
1968
|
+
if (!guard.ok) return json(res, guard.status, { error: guard.error });
|
|
1969
|
+
const body = await readBody(req, AGENT_BODY_LIMIT);
|
|
1970
|
+
const payload = parseRespond(body);
|
|
1971
|
+
if (typeof body.itemId !== "string" || !payload) return json(res, 400, { error: "invalid response" });
|
|
1972
|
+
const ok = runner.respond(id, body.itemId, payload, parseTurnSettings(body));
|
|
1973
|
+
return json(res, ok ? 200 : 409, ok ? { ok } : { error: "nothing pending" });
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
next();
|
|
1977
|
+
} catch (err) {
|
|
1978
|
+
if (err instanceof BodyTooLargeError) return json(res, 413, { error: err.message });
|
|
1979
|
+
json(res, 500, { error: String(err.message ?? err) });
|
|
1980
|
+
}
|
|
1981
|
+
});
|
|
1982
|
+
}
|
|
1983
|
+
//#endregion
|
|
16
1984
|
//#region src/editing/babel-walk.ts
|
|
17
1985
|
const SKIP_KEYS = /* @__PURE__ */ new Set([
|
|
18
1986
|
"loc",
|
|
@@ -1488,6 +3456,40 @@ function registerAssetRoutes(server, ctx) {
|
|
|
1488
3456
|
});
|
|
1489
3457
|
}
|
|
1490
3458
|
//#endregion
|
|
3459
|
+
//#region src/editing/page-entries.ts
|
|
3460
|
+
/**
|
|
3461
|
+
* The deck's page array as written, one entry per page, with whitespace
|
|
3462
|
+
* collapsed so reformatting the file keeps every entry the same. `null` when
|
|
3463
|
+
* the array cannot be read one entry per page: a spread stands for any number
|
|
3464
|
+
* of pages, and a default export that is not an array literal hides them.
|
|
3465
|
+
*/
|
|
3466
|
+
function defaultExportEntriesInSource(source) {
|
|
3467
|
+
let body;
|
|
3468
|
+
try {
|
|
3469
|
+
body = parse(source, {
|
|
3470
|
+
sourceType: "module",
|
|
3471
|
+
plugins: ["typescript", "jsx"],
|
|
3472
|
+
errorRecovery: true
|
|
3473
|
+
}).program.body;
|
|
3474
|
+
} catch {
|
|
3475
|
+
return null;
|
|
3476
|
+
}
|
|
3477
|
+
for (const node of body) {
|
|
3478
|
+
if (node.type !== "ExportDefaultDeclaration") continue;
|
|
3479
|
+
let inner = node.declaration;
|
|
3480
|
+
while (inner.type === "TSAsExpression" || inner.type === "TSSatisfiesExpression") inner = inner.expression;
|
|
3481
|
+
if (inner.type !== "ArrayExpression") return null;
|
|
3482
|
+
const entries = [];
|
|
3483
|
+
for (const element of inner.elements) {
|
|
3484
|
+
if (!element || element.type === "SpreadElement") return null;
|
|
3485
|
+
if (element.start == null || element.end == null) return null;
|
|
3486
|
+
entries.push(source.slice(element.start, element.end).replace(/\s+/g, " "));
|
|
3487
|
+
}
|
|
3488
|
+
return entries;
|
|
3489
|
+
}
|
|
3490
|
+
return null;
|
|
3491
|
+
}
|
|
3492
|
+
//#endregion
|
|
1491
3493
|
//#region src/files/board-structure.ts
|
|
1492
3494
|
function sanitizeStructure(parsed) {
|
|
1493
3495
|
if (!parsed || typeof parsed !== "object") return null;
|
|
@@ -1535,16 +3537,87 @@ function reconcileColumns(columns, pageCount) {
|
|
|
1535
3537
|
function boardStructurePath(userCwd, slideId) {
|
|
1536
3538
|
return path.join(userCwd, ".open-slide", "board", `${slideId}.json`);
|
|
1537
3539
|
}
|
|
1538
|
-
|
|
3540
|
+
/** The whole board file. Columns, cards and links each own one field of it. */
|
|
3541
|
+
async function readBoardFile(file) {
|
|
1539
3542
|
try {
|
|
1540
|
-
|
|
3543
|
+
const parsed = JSON.parse(await fs.readFile(file, "utf8"));
|
|
3544
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
1541
3545
|
} catch {
|
|
1542
|
-
return
|
|
3546
|
+
return {};
|
|
1543
3547
|
}
|
|
1544
3548
|
}
|
|
3549
|
+
async function readBoardStructure(file) {
|
|
3550
|
+
return sanitizeStructure(await readBoardFile(file));
|
|
3551
|
+
}
|
|
3552
|
+
const pending = /* @__PURE__ */ new Map();
|
|
3553
|
+
/**
|
|
3554
|
+
* Rewrites the fields `fields` names and keeps every other one. Updates to the
|
|
3555
|
+
* same file run one after another: the columns and the cards are saved by
|
|
3556
|
+
* separate requests, and two read-modify-writes racing would drop one of them.
|
|
3557
|
+
*/
|
|
3558
|
+
function updateBoardFile(file, fields) {
|
|
3559
|
+
const run = (pending.get(file) ?? Promise.resolve()).catch(() => void 0).then(async () => {
|
|
3560
|
+
const current = await readBoardFile(file);
|
|
3561
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
3562
|
+
await fs.writeFile(file, `${JSON.stringify({
|
|
3563
|
+
...current,
|
|
3564
|
+
...fields
|
|
3565
|
+
}, null, 2)}\n`, "utf8");
|
|
3566
|
+
});
|
|
3567
|
+
pending.set(file, run);
|
|
3568
|
+
run.finally(() => {
|
|
3569
|
+
if (pending.get(file) === run) pending.delete(file);
|
|
3570
|
+
}).catch(() => void 0);
|
|
3571
|
+
return run;
|
|
3572
|
+
}
|
|
1545
3573
|
async function writeBoardStructure(file, structure) {
|
|
1546
|
-
await
|
|
1547
|
-
|
|
3574
|
+
await updateBoardFile(file, { columns: structure.columns });
|
|
3575
|
+
}
|
|
3576
|
+
//#endregion
|
|
3577
|
+
//#region src/files/board-cards.ts
|
|
3578
|
+
const CARD_STATUSES = [
|
|
3579
|
+
"todo",
|
|
3580
|
+
"doing",
|
|
3581
|
+
"review",
|
|
3582
|
+
"done"
|
|
3583
|
+
];
|
|
3584
|
+
const TITLE_MAX = 80;
|
|
3585
|
+
const MEMO_MAX = 2e3;
|
|
3586
|
+
function isCardStatus(value) {
|
|
3587
|
+
return typeof value === "string" && CARD_STATUSES.includes(value);
|
|
3588
|
+
}
|
|
3589
|
+
function cleanCard(raw) {
|
|
3590
|
+
if (!raw || typeof raw !== "object") return null;
|
|
3591
|
+
const { title, status, memo } = raw;
|
|
3592
|
+
const card = {};
|
|
3593
|
+
if (typeof title === "string" && title.trim()) card.title = title.trim().slice(0, TITLE_MAX);
|
|
3594
|
+
if (isCardStatus(status)) card.status = status;
|
|
3595
|
+
if (typeof memo === "string" && memo.trim()) card.memo = memo.trim().slice(0, MEMO_MAX);
|
|
3596
|
+
return Object.keys(card).length > 0 ? card : null;
|
|
3597
|
+
}
|
|
3598
|
+
function sanitizeCards(raw) {
|
|
3599
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
3600
|
+
const cards = {};
|
|
3601
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
3602
|
+
const card = cleanCard(value);
|
|
3603
|
+
if (key && card) cards[key] = card;
|
|
3604
|
+
}
|
|
3605
|
+
return cards;
|
|
3606
|
+
}
|
|
3607
|
+
const sameLink = (link, a, b) => link.a === a && link.b === b || link.a === b && link.b === a;
|
|
3608
|
+
function linkCards(links, a, b) {
|
|
3609
|
+
if (!a || !b || a === b || links.some((link) => sameLink(link, a, b))) return links;
|
|
3610
|
+
return [...links, {
|
|
3611
|
+
a,
|
|
3612
|
+
b
|
|
3613
|
+
}];
|
|
3614
|
+
}
|
|
3615
|
+
function sanitizeLinks(raw) {
|
|
3616
|
+
if (!Array.isArray(raw)) return [];
|
|
3617
|
+
return raw.reduce((links, item) => {
|
|
3618
|
+
const { a, b } = item ?? {};
|
|
3619
|
+
return typeof a === "string" && typeof b === "string" ? linkCards(links, a, b) : links;
|
|
3620
|
+
}, []);
|
|
1548
3621
|
}
|
|
1549
3622
|
//#endregion
|
|
1550
3623
|
//#region src/vite/routes/board.ts
|
|
@@ -1581,6 +3654,34 @@ function registerBoardRoutes(server, ctx) {
|
|
|
1581
3654
|
await writeBoardStructure(boardStructurePath(ctx.userCwd, slideId), structure);
|
|
1582
3655
|
return json(res, 200, { ok: true });
|
|
1583
3656
|
}
|
|
3657
|
+
if (method === "GET" && url.pathname === "/cards") {
|
|
3658
|
+
const slideId = url.searchParams.get("slideId") ?? "";
|
|
3659
|
+
if (!resolveSlideEntryPath(ctx, slideId)) return json(res, 400, { error: "invalid slideId" });
|
|
3660
|
+
const stored = await readBoardFile(boardStructurePath(ctx.userCwd, slideId));
|
|
3661
|
+
return json(res, 200, {
|
|
3662
|
+
cards: sanitizeCards(stored.cards),
|
|
3663
|
+
links: sanitizeLinks(stored.links)
|
|
3664
|
+
});
|
|
3665
|
+
}
|
|
3666
|
+
if (method === "PUT" && url.pathname === "/cards") {
|
|
3667
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
3668
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
3669
|
+
const body = await readBody(req);
|
|
3670
|
+
const slideId = body.slideId ?? "";
|
|
3671
|
+
if (!resolveSlideEntryPath(ctx, slideId)) return json(res, 400, { error: "invalid slideId" });
|
|
3672
|
+
await updateBoardFile(boardStructurePath(ctx.userCwd, slideId), {
|
|
3673
|
+
cards: sanitizeCards(body.cards),
|
|
3674
|
+
links: sanitizeLinks(body.links)
|
|
3675
|
+
});
|
|
3676
|
+
return json(res, 200, { ok: true });
|
|
3677
|
+
}
|
|
3678
|
+
if (method === "GET" && url.pathname === "/entries") {
|
|
3679
|
+
const slideId = url.searchParams.get("slideId") ?? "";
|
|
3680
|
+
const entry = resolveSlideEntryPath(ctx, slideId);
|
|
3681
|
+
if (!entry) return json(res, 400, { error: "invalid slideId" });
|
|
3682
|
+
const source = await readSlideSource(entry);
|
|
3683
|
+
return json(res, 200, { entries: source ? defaultExportEntriesInSource(source) : null });
|
|
3684
|
+
}
|
|
1584
3685
|
next();
|
|
1585
3686
|
} catch (err) {
|
|
1586
3687
|
json(res, 500, { error: String(err.message ?? err) });
|
|
@@ -1597,6 +3698,24 @@ function b64urlDecode(s) {
|
|
|
1597
3698
|
const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - s.length % 4);
|
|
1598
3699
|
return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64").toString("utf8");
|
|
1599
3700
|
}
|
|
3701
|
+
function isFiniteNumber(value) {
|
|
3702
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
3703
|
+
}
|
|
3704
|
+
function parseCommentRect(value) {
|
|
3705
|
+
if (!value || typeof value !== "object") return void 0;
|
|
3706
|
+
const { x, y, w, h } = value;
|
|
3707
|
+
if (!isFiniteNumber(x) || !isFiniteNumber(y) || !isFiniteNumber(w) || !isFiniteNumber(h)) return void 0;
|
|
3708
|
+
if (w <= 0 || h <= 0) return void 0;
|
|
3709
|
+
return {
|
|
3710
|
+
x: Math.round(x),
|
|
3711
|
+
y: Math.round(y),
|
|
3712
|
+
w: Math.round(w),
|
|
3713
|
+
h: Math.round(h)
|
|
3714
|
+
};
|
|
3715
|
+
}
|
|
3716
|
+
function parseCommentPage(value) {
|
|
3717
|
+
return isFiniteNumber(value) && value >= 0 ? Math.floor(value) : void 0;
|
|
3718
|
+
}
|
|
1600
3719
|
function parseMarkers(source) {
|
|
1601
3720
|
const comments = [];
|
|
1602
3721
|
const lines = source.split("\n");
|
|
@@ -1611,12 +3730,36 @@ function parseMarkers(source) {
|
|
|
1611
3730
|
line: i + 1,
|
|
1612
3731
|
ts,
|
|
1613
3732
|
note: payload.note,
|
|
1614
|
-
hint: payload.hint
|
|
3733
|
+
hint: payload.hint,
|
|
3734
|
+
rect: parseCommentRect(payload.rect),
|
|
3735
|
+
page: parseCommentPage(payload.page)
|
|
1615
3736
|
});
|
|
1616
3737
|
} catch {}
|
|
1617
3738
|
}
|
|
1618
3739
|
return comments;
|
|
1619
3740
|
}
|
|
3741
|
+
function markerColumn(source, line) {
|
|
3742
|
+
const index = (source.split("\n")[line - 1] ?? "").search(/\{\/\*\s*@slide-comment/);
|
|
3743
|
+
return index === -1 ? 0 : index;
|
|
3744
|
+
}
|
|
3745
|
+
function resolveTargets(source, comments) {
|
|
3746
|
+
const ast = parseSource(source);
|
|
3747
|
+
return comments.map((comment) => {
|
|
3748
|
+
if (!ast) return {
|
|
3749
|
+
...comment,
|
|
3750
|
+
target: null
|
|
3751
|
+
};
|
|
3752
|
+
const column = markerColumn(source, comment.line);
|
|
3753
|
+
const start = findJsxAncestors(ast, comment.line, column).find((node) => t.isJSXElement(node))?.loc?.start;
|
|
3754
|
+
return {
|
|
3755
|
+
...comment,
|
|
3756
|
+
target: start ? {
|
|
3757
|
+
line: start.line,
|
|
3758
|
+
column: start.column
|
|
3759
|
+
} : null
|
|
3760
|
+
};
|
|
3761
|
+
});
|
|
3762
|
+
}
|
|
1620
3763
|
function newCommentId() {
|
|
1621
3764
|
return shortId("c");
|
|
1622
3765
|
}
|
|
@@ -1638,11 +3781,43 @@ function lineIndent(source, lineNumber) {
|
|
|
1638
3781
|
}
|
|
1639
3782
|
function planInsertion(source, target) {
|
|
1640
3783
|
if (t.isJSXElement(target) && target.openingElement.selfClosing) return null;
|
|
3784
|
+
const offset = (t.isJSXFragment(target) ? target.openingFragment : target.openingElement).end ?? 0;
|
|
1641
3785
|
return {
|
|
1642
|
-
offset
|
|
1643
|
-
indent: `${lineIndent(source, target.loc?.start.line ?? 1)}
|
|
3786
|
+
offset,
|
|
3787
|
+
indent: `${lineIndent(source, target.loc?.start.line ?? 1)} `,
|
|
3788
|
+
breakAfter: !/^[ \t]*\r?\n/.test(source.slice(offset))
|
|
1644
3789
|
};
|
|
1645
3790
|
}
|
|
3791
|
+
function buildMarker(plan, id, ts, encodedText) {
|
|
3792
|
+
const marker = `\n${plan.indent}{/* @slide-comment id="${id}" ts="${ts}" text="${encodedText}" */}`;
|
|
3793
|
+
return plan.breakAfter ? `${marker}\n${plan.indent}` : marker;
|
|
3794
|
+
}
|
|
3795
|
+
function updateMarkerNote(source, id, note) {
|
|
3796
|
+
const re = new RegExp(`(\\{\\/\\*\\s*@slide-comment\\s+id="${id}"\\s+ts="[^"]+"\\s+text=")([A-Za-z0-9_\\-]+={0,2})("\\s*\\*\\/\\})`);
|
|
3797
|
+
const m = source.match(re);
|
|
3798
|
+
if (!m) return null;
|
|
3799
|
+
let payload;
|
|
3800
|
+
try {
|
|
3801
|
+
payload = JSON.parse(b64urlDecode(m[2]));
|
|
3802
|
+
} catch {
|
|
3803
|
+
return null;
|
|
3804
|
+
}
|
|
3805
|
+
const encoded = b64urlEncode(JSON.stringify({
|
|
3806
|
+
...payload,
|
|
3807
|
+
note
|
|
3808
|
+
}));
|
|
3809
|
+
return source.replace(re, `$1${encoded}$3`);
|
|
3810
|
+
}
|
|
3811
|
+
function removeMarker(source, id) {
|
|
3812
|
+
const lines = source.split("\n");
|
|
3813
|
+
const idRe = markerDeleteRegex(id);
|
|
3814
|
+
const hit = lines.findIndex((line) => idRe.test(line));
|
|
3815
|
+
if (hit === -1) return null;
|
|
3816
|
+
const stripped = lines[hit].replace(idRe, "");
|
|
3817
|
+
if (stripped.trim() === "") lines.splice(hit, 1);
|
|
3818
|
+
else lines[hit] = stripped;
|
|
3819
|
+
return lines.join("\n");
|
|
3820
|
+
}
|
|
1646
3821
|
function findInsertion(source, line, column) {
|
|
1647
3822
|
const ast = parseSource(source);
|
|
1648
3823
|
if (!ast) return null;
|
|
@@ -1669,7 +3844,7 @@ function registerCommentRoutes(server, ctx) {
|
|
|
1669
3844
|
if (!file) return json(res, 400, { error: "invalid slideId" });
|
|
1670
3845
|
const source = await readSlideSource(file);
|
|
1671
3846
|
if (source === null) return json(res, 404, { error: "slide not found" });
|
|
1672
|
-
return json(res, 200, { comments: parseMarkers(source) });
|
|
3847
|
+
return json(res, 200, { comments: resolveTargets(source, parseMarkers(source)) });
|
|
1673
3848
|
}
|
|
1674
3849
|
if (method === "POST" && url.pathname === "/add") {
|
|
1675
3850
|
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
@@ -1686,11 +3861,13 @@ function registerCommentRoutes(server, ctx) {
|
|
|
1686
3861
|
if (!plan) return json(res, 422, { error: `could not find a JSX container around line ${body.line}. Try clicking a different element.` });
|
|
1687
3862
|
const id = newCommentId();
|
|
1688
3863
|
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
1689
|
-
const payload =
|
|
3864
|
+
const payload = {
|
|
1690
3865
|
note: body.text,
|
|
1691
|
-
hint: body.hint
|
|
1692
|
-
|
|
1693
|
-
|
|
3866
|
+
hint: body.hint,
|
|
3867
|
+
rect: parseCommentRect(body.rect),
|
|
3868
|
+
page: parseCommentPage(body.page)
|
|
3869
|
+
};
|
|
3870
|
+
const marker = buildMarker(plan, id, ts, b64urlEncode(JSON.stringify(payload)));
|
|
1694
3871
|
const next = source.slice(0, plan.offset) + marker + source.slice(plan.offset);
|
|
1695
3872
|
await fs.writeFile(file, next, "utf8");
|
|
1696
3873
|
const markerLine = offsetToLine(next, plan.offset + 1);
|
|
@@ -1699,6 +3876,22 @@ function registerCommentRoutes(server, ctx) {
|
|
|
1699
3876
|
line: markerLine
|
|
1700
3877
|
});
|
|
1701
3878
|
}
|
|
3879
|
+
if (method === "PUT" && url.pathname.startsWith("/")) {
|
|
3880
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
3881
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
3882
|
+
const id = url.pathname.slice(1);
|
|
3883
|
+
if (!/^c-[a-f0-9]+$/.test(id)) return json(res, 400, { error: "invalid id" });
|
|
3884
|
+
const body = await readBody(req);
|
|
3885
|
+
const file = resolveSlideEntryPath(ctx, body.slideId ?? "");
|
|
3886
|
+
if (!file) return json(res, 400, { error: "invalid slideId" });
|
|
3887
|
+
if (!body.text || typeof body.text !== "string") return json(res, 400, { error: "missing text" });
|
|
3888
|
+
const source = await readSlideSource(file);
|
|
3889
|
+
if (source === null) return json(res, 404, { error: "slide not found" });
|
|
3890
|
+
const next = updateMarkerNote(source, id, body.text);
|
|
3891
|
+
if (next === null) return json(res, 404, { error: "marker not found" });
|
|
3892
|
+
await fs.writeFile(file, next, "utf8");
|
|
3893
|
+
return json(res, 200, { ok: true });
|
|
3894
|
+
}
|
|
1702
3895
|
if (method === "DELETE" && url.pathname.startsWith("/")) {
|
|
1703
3896
|
const requestCheck = validateMutationRequest(req);
|
|
1704
3897
|
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
@@ -1709,12 +3902,9 @@ function registerCommentRoutes(server, ctx) {
|
|
|
1709
3902
|
if (!file) return json(res, 400, { error: "invalid slideId" });
|
|
1710
3903
|
const source = await readSlideSource(file);
|
|
1711
3904
|
if (source === null) return json(res, 404, { error: "slide not found" });
|
|
1712
|
-
const
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
if (hit === -1) return json(res, 404, { error: "marker not found" });
|
|
1716
|
-
lines.splice(hit, 1);
|
|
1717
|
-
await fs.writeFile(file, lines.join("\n"), "utf8");
|
|
3905
|
+
const next = removeMarker(source, id);
|
|
3906
|
+
if (next === null) return json(res, 404, { error: "marker not found" });
|
|
3907
|
+
await fs.writeFile(file, next, "utf8");
|
|
1718
3908
|
return json(res, 200, { ok: true });
|
|
1719
3909
|
}
|
|
1720
3910
|
next();
|
|
@@ -2133,6 +4323,18 @@ function registerSlideRoutes(server, ctx) {
|
|
|
2133
4323
|
const url = new URL(req.url ?? "/", "http://local");
|
|
2134
4324
|
const method = req.method ?? "GET";
|
|
2135
4325
|
try {
|
|
4326
|
+
if (url.pathname === "/" && method === "POST") {
|
|
4327
|
+
const requestCheck = validateMutationRequest(req);
|
|
4328
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
4329
|
+
const created = await createBlankSlideDir(ctx.slidesRoot, ctx.frameworkSpecifier);
|
|
4330
|
+
if (!created.ok) return json(res, created.status, { error: created.error });
|
|
4331
|
+
recordWrite(created.file);
|
|
4332
|
+
invalidateSlidesModule(server);
|
|
4333
|
+
return json(res, 200, {
|
|
4334
|
+
ok: true,
|
|
4335
|
+
slideId: created.slideId
|
|
4336
|
+
});
|
|
4337
|
+
}
|
|
2136
4338
|
const reorderMatch = url.pathname.match(/^\/([^/]+)\/reorder$/);
|
|
2137
4339
|
if (reorderMatch && method === "PUT") {
|
|
2138
4340
|
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
@@ -2357,6 +4559,7 @@ function apiPlugin(opts) {
|
|
|
2357
4559
|
registerFolderRoutes(server, ctx);
|
|
2358
4560
|
registerUpdateRoutes(server, ctx);
|
|
2359
4561
|
registerRestartRoutes(server);
|
|
4562
|
+
registerAgentRoutes(server, ctx);
|
|
2360
4563
|
}
|
|
2361
4564
|
};
|
|
2362
4565
|
}
|
|
@@ -2868,24 +5071,6 @@ function locTagsPlugin(opts) {
|
|
|
2868
5071
|
};
|
|
2869
5072
|
}
|
|
2870
5073
|
//#endregion
|
|
2871
|
-
//#region src/vite/recent-writes.ts
|
|
2872
|
-
const recentWrites = /* @__PURE__ */ new Map();
|
|
2873
|
-
/** Record that `file` was just written by us. */
|
|
2874
|
-
function recordWrite(file, now = Date.now()) {
|
|
2875
|
-
recentWrites.set(file, now);
|
|
2876
|
-
}
|
|
2877
|
-
/**
|
|
2878
|
-
* True if `file` was recorded within the recent-write window. Expired entries
|
|
2879
|
-
* are pruned so a stale path can no longer suppress a later genuine edit.
|
|
2880
|
-
*/
|
|
2881
|
-
function hasRecentWrite(file, now = Date.now()) {
|
|
2882
|
-
const ts = recentWrites.get(file);
|
|
2883
|
-
if (ts == null) return false;
|
|
2884
|
-
if (now - ts < 1500) return true;
|
|
2885
|
-
recentWrites.delete(file);
|
|
2886
|
-
return false;
|
|
2887
|
-
}
|
|
2888
|
-
//#endregion
|
|
2889
5074
|
//#region src/vite/notes-plugin.ts
|
|
2890
5075
|
function findNotesExport(ast) {
|
|
2891
5076
|
for (const stmt of ast.program.body) {
|
|
@@ -2945,343 +5130,136 @@ function applyNotesEdit(source, index, text) {
|
|
|
2945
5130
|
if (text === "") return {
|
|
2946
5131
|
ok: true,
|
|
2947
5132
|
source
|
|
2948
|
-
};
|
|
2949
|
-
const block = [
|
|
2950
|
-
"",
|
|
2951
|
-
"",
|
|
2952
|
-
"export const notes: (string | undefined)[] = [",
|
|
2953
|
-
...[...Array.from({ length: index }, () => "undefined"), literal].map((s) => ` ${s},`),
|
|
2954
|
-
"];",
|
|
2955
|
-
""
|
|
2956
|
-
].join("\n");
|
|
2957
|
-
const offset = findInsertionOffset(ast, source);
|
|
2958
|
-
return {
|
|
2959
|
-
ok: true,
|
|
2960
|
-
source: source.slice(0, offset) + block + source.slice(offset)
|
|
2961
|
-
};
|
|
2962
|
-
}
|
|
2963
|
-
const elementTexts = [];
|
|
2964
|
-
for (const el of found.elements) {
|
|
2965
|
-
if (el === null) {
|
|
2966
|
-
elementTexts.push("undefined");
|
|
2967
|
-
continue;
|
|
2968
|
-
}
|
|
2969
|
-
if (typeof el.start !== "number" || typeof el.end !== "number") return {
|
|
2970
|
-
ok: false,
|
|
2971
|
-
status: 422,
|
|
2972
|
-
error: "`notes` element missing source range"
|
|
2973
|
-
};
|
|
2974
|
-
elementTexts.push(source.slice(el.start, el.end));
|
|
2975
|
-
}
|
|
2976
|
-
while (elementTexts.length <= index) elementTexts.push("undefined");
|
|
2977
|
-
elementTexts[index] = literal;
|
|
2978
|
-
while (elementTexts.length > 0 && elementTexts[elementTexts.length - 1] === "undefined") elementTexts.pop();
|
|
2979
|
-
const replacement = elementTexts.length === 0 ? "[]" : `[\n${elementTexts.map((s) => ` ${s},`).join("\n")}\n]`;
|
|
2980
|
-
return {
|
|
2981
|
-
ok: true,
|
|
2982
|
-
source: source.slice(0, found.arrayStart) + replacement + source.slice(found.arrayEnd)
|
|
2983
|
-
};
|
|
2984
|
-
}
|
|
2985
|
-
function notesPlugin(opts) {
|
|
2986
|
-
const slidesRoot = path.resolve(opts.userCwd, opts.slidesDir ?? "slides");
|
|
2987
|
-
return {
|
|
2988
|
-
name: "lemonriver:notes",
|
|
2989
|
-
apply: "serve",
|
|
2990
|
-
handleHotUpdate(ctx) {
|
|
2991
|
-
if (hasRecentWrite(ctx.file)) return [];
|
|
2992
|
-
},
|
|
2993
|
-
configureServer(server) {
|
|
2994
|
-
server.middlewares.use("/__notes", async (req, res, next) => {
|
|
2995
|
-
const url = new URL(req.url ?? "/", "http://local");
|
|
2996
|
-
if ((req.method ?? "GET") !== "PUT" || url.pathname !== "/") return next();
|
|
2997
|
-
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
2998
|
-
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
2999
|
-
try {
|
|
3000
|
-
const body = await readBody(req);
|
|
3001
|
-
const slideId = body.slideId ?? "";
|
|
3002
|
-
const file = resolveSlideEntry(slidesRoot, slideId);
|
|
3003
|
-
if (!file) return json(res, 400, { error: "invalid slideId" });
|
|
3004
|
-
if (typeof body.index !== "number") return json(res, 400, { error: "missing index" });
|
|
3005
|
-
if (typeof body.text !== "string") return json(res, 400, { error: "missing text" });
|
|
3006
|
-
const source = await readSlideSource(file);
|
|
3007
|
-
if (source === null) return json(res, 404, { error: "slide not found" });
|
|
3008
|
-
const result = applyNotesEdit(source, body.index, body.text);
|
|
3009
|
-
if (!result.ok) return json(res, result.status, { error: result.error });
|
|
3010
|
-
const changed = result.source !== source;
|
|
3011
|
-
if (changed) {
|
|
3012
|
-
recordWrite(file);
|
|
3013
|
-
await fs.writeFile(file, result.source, "utf8");
|
|
3014
|
-
}
|
|
3015
|
-
return json(res, 200, {
|
|
3016
|
-
ok: true,
|
|
3017
|
-
changed
|
|
3018
|
-
});
|
|
3019
|
-
} catch (err) {
|
|
3020
|
-
json(res, 500, { error: String(err.message ?? err) });
|
|
3021
|
-
}
|
|
3022
|
-
});
|
|
3023
|
-
}
|
|
3024
|
-
};
|
|
3025
|
-
}
|
|
3026
|
-
//#endregion
|
|
3027
|
-
//#region src/vite/open-slide-plugin.ts
|
|
3028
|
-
const SLIDES_VMOD = "virtual:open-slide/slides";
|
|
3029
|
-
const CONFIG_VMOD = "virtual:open-slide/config";
|
|
3030
|
-
const FOLDERS_VMOD = "virtual:open-slide/folders";
|
|
3031
|
-
function resolved$1(id) {
|
|
3032
|
-
return `\0${id}`;
|
|
3033
|
-
}
|
|
3034
|
-
async function findSlides(userCwd, slidesDir) {
|
|
3035
|
-
const abs = path.resolve(userCwd, slidesDir);
|
|
3036
|
-
if (!existsSync(abs)) return [];
|
|
3037
|
-
return (await fg("*/index.{tsx,jsx,ts,js}", {
|
|
3038
|
-
cwd: abs,
|
|
3039
|
-
absolute: true,
|
|
3040
|
-
onlyFiles: true
|
|
3041
|
-
})).sort();
|
|
3042
|
-
}
|
|
3043
|
-
function toId(absFile, slidesRoot) {
|
|
3044
|
-
return path.relative(slidesRoot, absFile).split(path.sep)[0];
|
|
3045
|
-
}
|
|
3046
|
-
const META_THEME_RE = /(?:^|[\s,{])theme\s*:\s*['"]([^'"]+)['"]/;
|
|
3047
|
-
const META_CREATED_AT_RE = /(?:^|[\s,{])createdAt\s*:\s*['"]([^'"]+)['"]/;
|
|
3048
|
-
function extractMeta(src) {
|
|
3049
|
-
const empty = {
|
|
3050
|
-
theme: null,
|
|
3051
|
-
createdAt: null
|
|
3052
|
-
};
|
|
3053
|
-
const metaStart = src.search(/export\s+const\s+meta\b/);
|
|
3054
|
-
if (metaStart === -1) return empty;
|
|
3055
|
-
const eqIdx = src.indexOf("=", metaStart);
|
|
3056
|
-
if (eqIdx === -1) return empty;
|
|
3057
|
-
const openBrace = src.indexOf("{", eqIdx);
|
|
3058
|
-
if (openBrace === -1) return empty;
|
|
3059
|
-
let depth = 0;
|
|
3060
|
-
let closeBrace = -1;
|
|
3061
|
-
for (let i = openBrace; i < src.length; i++) {
|
|
3062
|
-
const ch = src[i];
|
|
3063
|
-
if (ch === "{") depth++;
|
|
3064
|
-
else if (ch === "}") {
|
|
3065
|
-
depth--;
|
|
3066
|
-
if (depth === 0) {
|
|
3067
|
-
closeBrace = i;
|
|
3068
|
-
break;
|
|
3069
|
-
}
|
|
3070
|
-
}
|
|
3071
|
-
}
|
|
3072
|
-
if (closeBrace === -1) return empty;
|
|
3073
|
-
const body = src.slice(openBrace + 1, closeBrace);
|
|
3074
|
-
const themeMatch = body.match(META_THEME_RE);
|
|
3075
|
-
const createdAtMatch = body.match(META_CREATED_AT_RE);
|
|
3076
|
-
return {
|
|
3077
|
-
theme: themeMatch ? themeMatch[1] : null,
|
|
3078
|
-
createdAt: createdAtMatch ? createdAtMatch[1] : null
|
|
3079
|
-
};
|
|
3080
|
-
}
|
|
3081
|
-
async function readSlideMeta(abs) {
|
|
3082
|
-
try {
|
|
3083
|
-
return extractMeta(await fs.readFile(abs, "utf8"));
|
|
3084
|
-
} catch {
|
|
5133
|
+
};
|
|
5134
|
+
const block = [
|
|
5135
|
+
"",
|
|
5136
|
+
"",
|
|
5137
|
+
"export const notes: (string | undefined)[] = [",
|
|
5138
|
+
...[...Array.from({ length: index }, () => "undefined"), literal].map((s) => ` ${s},`),
|
|
5139
|
+
"];",
|
|
5140
|
+
""
|
|
5141
|
+
].join("\n");
|
|
5142
|
+
const offset = findInsertionOffset(ast, source);
|
|
3085
5143
|
return {
|
|
3086
|
-
|
|
3087
|
-
|
|
5144
|
+
ok: true,
|
|
5145
|
+
source: source.slice(0, offset) + block + source.slice(offset)
|
|
3088
5146
|
};
|
|
3089
5147
|
}
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
}
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
const importPath = isDev ? `@fs/${normalizePath(abs).replace(/^\/+/, "")}` : abs;
|
|
3101
|
-
const meta = await readSlideMeta(abs);
|
|
3102
|
-
return {
|
|
3103
|
-
id,
|
|
3104
|
-
importPath,
|
|
3105
|
-
theme: meta.theme,
|
|
3106
|
-
createdAt: parseCreatedAtMs(meta.createdAt)
|
|
5148
|
+
const elementTexts = [];
|
|
5149
|
+
for (const el of found.elements) {
|
|
5150
|
+
if (el === null) {
|
|
5151
|
+
elementTexts.push("undefined");
|
|
5152
|
+
continue;
|
|
5153
|
+
}
|
|
5154
|
+
if (typeof el.start !== "number" || typeof el.end !== "number") return {
|
|
5155
|
+
ok: false,
|
|
5156
|
+
status: 422,
|
|
5157
|
+
error: "`notes` element missing source range"
|
|
3107
5158
|
};
|
|
3108
|
-
|
|
3109
|
-
const entries = scanned.filter((e) => SLIDE_ID_RE.test(e.id));
|
|
3110
|
-
const ignored = scanned.filter((e) => !SLIDE_ID_RE.test(e.id)).map((e) => e.id);
|
|
3111
|
-
const ids = JSON.stringify(entries.map((e) => e.id).sort());
|
|
3112
|
-
const themesMap = {};
|
|
3113
|
-
const createdAtMap = {};
|
|
3114
|
-
for (const e of entries) {
|
|
3115
|
-
if (e.theme) themesMap[e.id] = e.theme;
|
|
3116
|
-
if (e.createdAt !== null) createdAtMap[e.id] = e.createdAt;
|
|
5159
|
+
elementTexts.push(source.slice(el.start, el.end));
|
|
3117
5160
|
}
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
5161
|
+
while (elementTexts.length <= index) elementTexts.push("undefined");
|
|
5162
|
+
elementTexts[index] = literal;
|
|
5163
|
+
while (elementTexts.length > 0 && elementTexts[elementTexts.length - 1] === "undefined") elementTexts.pop();
|
|
5164
|
+
const replacement = elementTexts.length === 0 ? "[]" : `[\n${elementTexts.map((s) => ` ${s},`).join("\n")}\n]`;
|
|
3121
5165
|
return {
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
export const slideThemes = ${themesJson};
|
|
3125
|
-
export const slideCreatedAt = ${createdAtJson};
|
|
3126
|
-
${isDev ? `
|
|
3127
|
-
const slideImportTokens = ${importTokens};
|
|
3128
|
-
if (import.meta.hot) {
|
|
3129
|
-
import.meta.hot.on('open-slide:slide-changed', (data) => {
|
|
3130
|
-
const ids = Array.isArray(data?.slideIds) ? data.slideIds : [];
|
|
3131
|
-
const token = Date.now();
|
|
3132
|
-
for (const id of ids) {
|
|
3133
|
-
if (Object.prototype.hasOwnProperty.call(slideImportTokens, id)) slideImportTokens[id] = token;
|
|
3134
|
-
}
|
|
3135
|
-
});
|
|
3136
|
-
}
|
|
3137
|
-
` : ""}
|
|
3138
|
-
|
|
3139
|
-
export async function loadSlide(id) {
|
|
3140
|
-
switch (id) {
|
|
3141
|
-
${entries.map((e) => {
|
|
3142
|
-
const importExpr = isDev ? `import(/* @vite-ignore */ import.meta.env.BASE_URL + ${JSON.stringify(`${e.importPath}?t=`)} + slideImportTokens[${JSON.stringify(e.id)}])` : `import(${JSON.stringify(e.importPath)})`;
|
|
3143
|
-
return ` case ${JSON.stringify(e.id)}: return ${importExpr};`;
|
|
3144
|
-
}).join("\n")}
|
|
3145
|
-
default: throw new Error('Slide not found: ' + id);
|
|
3146
|
-
}
|
|
3147
|
-
}
|
|
3148
|
-
`,
|
|
3149
|
-
ignored
|
|
5166
|
+
ok: true,
|
|
5167
|
+
source: source.slice(0, found.arrayStart) + replacement + source.slice(found.arrayEnd)
|
|
3150
5168
|
};
|
|
3151
5169
|
}
|
|
3152
|
-
function
|
|
3153
|
-
const
|
|
3154
|
-
const slidesDir = config.slidesDir ?? "slides";
|
|
3155
|
-
const slidesRoot = path.resolve(userCwd, slidesDir);
|
|
3156
|
-
const manifestPath = foldersManifestPath(slidesRoot);
|
|
3157
|
-
let isDev = false;
|
|
3158
|
-
const slideIdForEntry = (p) => {
|
|
3159
|
-
const rel = path.relative(slidesRoot, p);
|
|
3160
|
-
if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
|
|
3161
|
-
const parts = rel.split(path.sep);
|
|
3162
|
-
if (parts.length !== 2) return null;
|
|
3163
|
-
if (!/^index\.(tsx|jsx|ts|js)$/.test(parts[1])) return null;
|
|
3164
|
-
return parts[0];
|
|
3165
|
-
};
|
|
3166
|
-
let slideChangeTimer = null;
|
|
3167
|
-
const pendingSlideChanges = /* @__PURE__ */ new Set();
|
|
3168
|
-
const queueSlideChanged = (server, id) => {
|
|
3169
|
-
pendingSlideChanges.add(id);
|
|
3170
|
-
if (slideChangeTimer) clearTimeout(slideChangeTimer);
|
|
3171
|
-
slideChangeTimer = setTimeout(() => {
|
|
3172
|
-
slideChangeTimer = null;
|
|
3173
|
-
const mod = server.moduleGraph.getModuleById(resolved$1(SLIDES_VMOD));
|
|
3174
|
-
if (mod) server.moduleGraph.invalidateModule(mod);
|
|
3175
|
-
const slideIds = Array.from(pendingSlideChanges);
|
|
3176
|
-
pendingSlideChanges.clear();
|
|
3177
|
-
server.ws.send({
|
|
3178
|
-
type: "custom",
|
|
3179
|
-
event: "open-slide:slide-changed",
|
|
3180
|
-
data: { slideIds }
|
|
3181
|
-
});
|
|
3182
|
-
}, 100);
|
|
3183
|
-
};
|
|
5170
|
+
function notesPlugin(opts) {
|
|
5171
|
+
const slidesRoot = path.resolve(opts.userCwd, opts.slidesDir ?? "slides");
|
|
3184
5172
|
return {
|
|
3185
|
-
name: "lemonriver",
|
|
3186
|
-
|
|
3187
|
-
isDev = env.command === "serve";
|
|
3188
|
-
},
|
|
3189
|
-
resolveId(id) {
|
|
3190
|
-
if (id === SLIDES_VMOD) return resolved$1(SLIDES_VMOD);
|
|
3191
|
-
if (id === CONFIG_VMOD) return resolved$1(CONFIG_VMOD);
|
|
3192
|
-
if (id === FOLDERS_VMOD) return resolved$1(FOLDERS_VMOD);
|
|
3193
|
-
return null;
|
|
3194
|
-
},
|
|
3195
|
-
async load(id) {
|
|
3196
|
-
if (id === resolved$1(SLIDES_VMOD)) {
|
|
3197
|
-
const { code, ignored } = await generateSlidesModule(await findSlides(userCwd, slidesDir), slidesRoot, isDev);
|
|
3198
|
-
for (const slideId of ignored) {
|
|
3199
|
-
if (warnedInvalidSlideIds.has(slideId)) continue;
|
|
3200
|
-
warnedInvalidSlideIds.add(slideId);
|
|
3201
|
-
this.warn(`Ignoring slide folder "${slideId}": slide ids must match ${SLIDE_ID_RE} (lowercase/uppercase letters, digits, "-", "_"). Rename the folder under "${slidesDir}/" to a kebab-case id so it appears in the browser and can be moved into folders.`);
|
|
3202
|
-
}
|
|
3203
|
-
return code;
|
|
3204
|
-
}
|
|
3205
|
-
if (id === resolved$1(CONFIG_VMOD)) {
|
|
3206
|
-
const userBuild = config.build ?? {};
|
|
3207
|
-
const buildResolved = isDev ? {
|
|
3208
|
-
showSlideBrowser: true,
|
|
3209
|
-
showSlideUi: true,
|
|
3210
|
-
allowHtmlDownload: true
|
|
3211
|
-
} : {
|
|
3212
|
-
showSlideBrowser: userBuild.showSlideBrowser ?? true,
|
|
3213
|
-
showSlideUi: userBuild.showSlideUi ?? true,
|
|
3214
|
-
allowHtmlDownload: userBuild.allowHtmlDownload ?? true
|
|
3215
|
-
};
|
|
3216
|
-
const resolvedConfig = {
|
|
3217
|
-
...config,
|
|
3218
|
-
build: buildResolved,
|
|
3219
|
-
version: coreVersion
|
|
3220
|
-
};
|
|
3221
|
-
return `export default ${JSON.stringify(resolvedConfig)};\n`;
|
|
3222
|
-
}
|
|
3223
|
-
if (id === resolved$1(FOLDERS_VMOD)) {
|
|
3224
|
-
const manifest = await readManifest(manifestPath);
|
|
3225
|
-
return `export default ${JSON.stringify(manifest)};\n`;
|
|
3226
|
-
}
|
|
3227
|
-
return null;
|
|
3228
|
-
},
|
|
5173
|
+
name: "lemonriver:notes",
|
|
5174
|
+
apply: "serve",
|
|
3229
5175
|
handleHotUpdate(ctx) {
|
|
3230
|
-
const slideId = slideIdForEntry(ctx.file);
|
|
3231
|
-
if (!slideId) return;
|
|
3232
5176
|
if (hasRecentWrite(ctx.file)) return [];
|
|
3233
|
-
queueSlideChanged(ctx.server, slideId);
|
|
3234
|
-
return [];
|
|
3235
5177
|
},
|
|
3236
5178
|
configureServer(server) {
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
const
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
}
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
server.watcher.on("change", (p) => {
|
|
3266
|
-
if (p === manifestPath) invalidateFolders();
|
|
5179
|
+
server.middlewares.use("/__notes", async (req, res, next) => {
|
|
5180
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
5181
|
+
if ((req.method ?? "GET") !== "PUT" || url.pathname !== "/") return next();
|
|
5182
|
+
const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
|
|
5183
|
+
if (!requestCheck.ok) return json(res, requestCheck.status, { error: requestCheck.error });
|
|
5184
|
+
try {
|
|
5185
|
+
const body = await readBody(req);
|
|
5186
|
+
const slideId = body.slideId ?? "";
|
|
5187
|
+
const file = resolveSlideEntry(slidesRoot, slideId);
|
|
5188
|
+
if (!file) return json(res, 400, { error: "invalid slideId" });
|
|
5189
|
+
if (typeof body.index !== "number") return json(res, 400, { error: "missing index" });
|
|
5190
|
+
if (typeof body.text !== "string") return json(res, 400, { error: "missing text" });
|
|
5191
|
+
const source = await readSlideSource(file);
|
|
5192
|
+
if (source === null) return json(res, 404, { error: "slide not found" });
|
|
5193
|
+
const result = applyNotesEdit(source, body.index, body.text);
|
|
5194
|
+
if (!result.ok) return json(res, result.status, { error: result.error });
|
|
5195
|
+
const changed = result.source !== source;
|
|
5196
|
+
if (changed) {
|
|
5197
|
+
recordWrite(file);
|
|
5198
|
+
await fs.writeFile(file, result.source, "utf8");
|
|
5199
|
+
}
|
|
5200
|
+
return json(res, 200, {
|
|
5201
|
+
ok: true,
|
|
5202
|
+
changed
|
|
5203
|
+
});
|
|
5204
|
+
} catch (err) {
|
|
5205
|
+
json(res, 500, { error: String(err.message ?? err) });
|
|
5206
|
+
}
|
|
3267
5207
|
});
|
|
3268
|
-
|
|
3269
|
-
|
|
5208
|
+
}
|
|
5209
|
+
};
|
|
5210
|
+
}
|
|
5211
|
+
//#endregion
|
|
5212
|
+
//#region src/vite/server-info-plugin.ts
|
|
5213
|
+
function serverInfoUrl(address, https, base) {
|
|
5214
|
+
let host = address.address === "::" || address.address === "0.0.0.0" ? "localhost" : address.address;
|
|
5215
|
+
if (host.includes(":")) host = `[${host}]`;
|
|
5216
|
+
const pathname = base.startsWith("/") ? base : "/";
|
|
5217
|
+
return `${https ? "https" : "http"}://${host}:${address.port}${pathname}`;
|
|
5218
|
+
}
|
|
5219
|
+
function serverInfoPlugin(opts) {
|
|
5220
|
+
const outDir = path.join(opts.userCwd, "node_modules", ".open-slide");
|
|
5221
|
+
const outFile = path.join(outDir, "server.json");
|
|
5222
|
+
return {
|
|
5223
|
+
name: "open-slide:server-info",
|
|
5224
|
+
apply: "serve",
|
|
5225
|
+
configureServer(server) {
|
|
5226
|
+
const httpServer = server.httpServer;
|
|
5227
|
+
if (!httpServer) return;
|
|
5228
|
+
let publication = Promise.resolve(null);
|
|
5229
|
+
httpServer.on("listening", () => {
|
|
5230
|
+
const address = httpServer.address();
|
|
5231
|
+
if (!address || typeof address === "string") return;
|
|
5232
|
+
const token = randomUUID();
|
|
5233
|
+
const body = {
|
|
5234
|
+
url: serverInfoUrl(address, Boolean(server.config.server.https), server.config.base),
|
|
5235
|
+
port: address.port,
|
|
5236
|
+
pid: process.pid,
|
|
5237
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5238
|
+
token
|
|
5239
|
+
};
|
|
5240
|
+
const tmpFile = `${outFile}.${token}.tmp`;
|
|
5241
|
+
publication = (async () => {
|
|
5242
|
+
try {
|
|
5243
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
5244
|
+
await fs.writeFile(tmpFile, `${JSON.stringify(body, null, 2)}\n`, "utf8");
|
|
5245
|
+
await fs.rename(tmpFile, outFile);
|
|
5246
|
+
return token;
|
|
5247
|
+
} catch {
|
|
5248
|
+
await fs.rm(tmpFile, { force: true }).catch(() => {});
|
|
5249
|
+
return null;
|
|
5250
|
+
}
|
|
5251
|
+
})();
|
|
3270
5252
|
});
|
|
3271
|
-
|
|
3272
|
-
|
|
5253
|
+
httpServer.on("close", async () => {
|
|
5254
|
+
const token = await publication;
|
|
5255
|
+
if (!token) return;
|
|
5256
|
+
try {
|
|
5257
|
+
if (JSON.parse(readFileSync(outFile, "utf8")).token === token) rmSync(outFile, { force: true });
|
|
5258
|
+
} catch {}
|
|
3273
5259
|
});
|
|
3274
5260
|
}
|
|
3275
5261
|
};
|
|
3276
5262
|
}
|
|
3277
|
-
async function loadUserConfig(userCwd) {
|
|
3278
|
-
const file = CONFIG_FILES.map((name) => path.join(userCwd, name)).find((p) => existsSync(p));
|
|
3279
|
-
if (!file) return {};
|
|
3280
|
-
return (await loadConfigFromFile({
|
|
3281
|
-
command: "serve",
|
|
3282
|
-
mode: "development"
|
|
3283
|
-
}, file, userCwd, "silent"))?.config ?? {};
|
|
3284
|
-
}
|
|
3285
5263
|
//#endregion
|
|
3286
5264
|
//#region src/vite/themes-plugin.ts
|
|
3287
5265
|
const THEMES_VMOD = "virtual:open-slide/themes";
|
|
@@ -3482,7 +5460,8 @@ async function createViteConfig(opts) {
|
|
|
3482
5460
|
userCwd,
|
|
3483
5461
|
slidesDir,
|
|
3484
5462
|
assetsDir,
|
|
3485
|
-
coreVersion: CORE_VERSION
|
|
5463
|
+
coreVersion: CORE_VERSION,
|
|
5464
|
+
agentEnabled: config.agent?.enabled ?? true
|
|
3486
5465
|
}),
|
|
3487
5466
|
notesPlugin({
|
|
3488
5467
|
userCwd,
|
|
@@ -3491,7 +5470,8 @@ async function createViteConfig(opts) {
|
|
|
3491
5470
|
currentPlugin({
|
|
3492
5471
|
userCwd,
|
|
3493
5472
|
slidesDir
|
|
3494
|
-
})
|
|
5473
|
+
}),
|
|
5474
|
+
serverInfoPlugin({ userCwd })
|
|
3495
5475
|
],
|
|
3496
5476
|
resolve: {
|
|
3497
5477
|
alias: {
|