@fullstackcraftllc/codevideo-mcp 1.0.0 → 1.1.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/README.md +101 -4
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3547 -1139
- package/dist/index.js.map +7 -0
- package/package.json +49 -28
- package/bin/darwin-arm64/codevideo-cli +0 -0
- package/bin/darwin-x64/codevideo-cli +0 -0
- package/bin/linux-x64/codevideo-cli +0 -0
- package/bin/win32-x64/codevideo-cli.exe +0 -0
- package/dist/src/index.d.ts +0 -1
- package/dist/src/prompts/instructions_to_create_a_course.d.ts +0 -1
- package/dist/src/prompts/instructions_to_create_lesson_actions.d.ts +0 -1
- package/dist/src/prompts/instructions_to_create_lesson_video.d.ts +0 -1
- package/dist/src/resources/get_action_names.d.ts +0 -4
- package/dist/src/resources/get_example_lesson.d.ts +0 -1
- package/dist/src/tools/add_actions_to_lesson.d.ts +0 -2
- package/dist/src/tools/add_final_snapshot_to_lesson.d.ts +0 -2
- package/dist/src/tools/add_initial_snapshot_to_lesson.d.ts +0 -2
- package/dist/src/tools/add_lesson_to_course.d.ts +0 -2
- package/dist/src/tools/create_course_with_initial_metadata.d.ts +0 -1
- package/dist/src/tools/critique_video_using_gemini.d.ts +0 -2
- package/dist/src/tools/get_current_actions.d.ts +0 -3
- package/dist/src/tools/get_empty_lesson_snapshot.d.ts +0 -1
- package/dist/src/tools/get_final_lesson_snapshot.d.ts +0 -2
- package/dist/src/tools/get_initial_lesson_state_from_local_repo.d.ts +0 -1
- package/dist/src/tools/is_repeatable_action.d.ts +0 -2
- package/dist/src/tools/make_blog_post.d.ts +0 -2
- package/dist/src/tools/make_html_web_page.d.ts +0 -2
- package/dist/src/tools/make_video.d.ts +0 -8
- package/dist/src/tools/set_current_actions.d.ts +0 -2
- package/dist/src/tools/validate_actions.d.ts +0 -6
- package/dist/src/utils/getBinaryPath.d.ts +0 -1
package/dist/index.js
CHANGED
|
@@ -1,23 +1,22 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
return `You are an expert senior software engineer and educator, and skilled in describing software concepts in a way that is easy for students to understand. You are going to create a course using the CodeVideo framework, a framework that uses simple JSON to generate software courses and lessons in a variety of formats (video, markdown, pdf, and more).
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import dotenv from "dotenv";
|
|
5
|
+
import fs9 from "node:fs";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
// package.json
|
|
9
|
+
var version = "1.1.0";
|
|
10
|
+
|
|
11
|
+
// src/index.ts
|
|
12
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
13
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
14
|
+
import { z as z2 } from "zod";
|
|
15
|
+
import { AllActionStrings, isRepeatableAction as isRepeatableAction2 } from "@fullstackcraftllc/codevideo-types";
|
|
16
|
+
|
|
17
|
+
// src/prompts/instructions_to_create_a_course.ts
|
|
18
|
+
var instructionsToCreateACourse = (additionalContext) => {
|
|
19
|
+
return `You are an expert senior software engineer and educator, and skilled in describing software concepts in a way that is easy for students to understand. You are going to create a course using the CodeVideo framework, a framework that uses simple JSON to generate software courses and lessons in a variety of formats (video, markdown, pdf, and more).
|
|
21
20
|
|
|
22
21
|
There are a few rules to uphold when making a CodeVideo course:
|
|
23
22
|
|
|
@@ -61,7 +60,7 @@ const instructionsToCreateACourse = (additionalContext) => {
|
|
|
61
60
|
1. Create the initial course object using the MCP tool \`codevideo_create_course_with_initial_metadata\`
|
|
62
61
|
2. Plan out the number of lessons needed and what they will be about
|
|
63
62
|
3. For each lesson, create a unique identifier, name, and description
|
|
64
|
-
4. Create the initial empty lesson snapshot for the first lesson using the MCP tool \`codevideo_get_empty_lesson_snapshot\`. In the case of
|
|
63
|
+
4. Create the initial empty lesson snapshot for the first lesson using the MCP tool \`codevideo_get_empty_lesson_snapshot\`. In the case of any lesson beyond the first lesson, generate the final lesson snapshot and copy it as the initial lesson snapshot.
|
|
65
64
|
5. Create actions for the lesson using the MCP tool \`codevideo_instructions_to_create_lesson_actions\`
|
|
66
65
|
6. Add actions to the lesson using the MCP tool \`codevideo_add_actions_to_lesson\`
|
|
67
66
|
7. Create the final lesson snapshot using the MCP tool \`codevideo_get_final_lesson_snapshot\`
|
|
@@ -73,9 +72,36 @@ const instructionsToCreateACourse = (additionalContext) => {
|
|
|
73
72
|
Good luck, and enjoy creating your course!`;
|
|
74
73
|
};
|
|
75
74
|
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
// src/prompts/actionReference.ts
|
|
76
|
+
import { AllActionDescriptions } from "@fullstackcraftllc/codevideo-types";
|
|
77
|
+
var actionReference = () => AllActionDescriptions.map((action) => `${action.name}: ${action.description} Value: ${action.valueDescription}`).join("\n");
|
|
78
|
+
|
|
79
|
+
// src/prompts/authoringRules.ts
|
|
80
|
+
var AUTHORING_RULES = `ACTION STYLE AND PACING (this is what makes lessons clear):
|
|
81
|
+
- This JSON array is always an array of simple IAction objects: { name: AllActions, value: string }. codevideo_get_action_names lists every action with what it does and what its value takes, so check the rows for the actions you use; codevideo_get_example_actions_array_by_keyword / codevideo_get_example_lessons_by_keyword give verified human-made examples.
|
|
82
|
+
- The "value" of any action is NEVER empty. Repeatable actions (e.g. editor-enter) take a numeric STRING value, usually "1"; check with codevideo_is_repeatable_action.
|
|
83
|
+
- Interleave speech with typing \u2014 no long silent blocks of code. Narrate the WHY before you write, and keep individual speak actions to 2-3 sentences; break longer narration into multiple author-speak-before actions.
|
|
84
|
+
- Mind the flow of time: speak about saving BEFORE the editor-save; speak about opening a terminal BEFORE terminal-open. REGARDLESS OF ORDER, only ever use the author-speak-before speaking action.
|
|
85
|
+
- Show every new file being made, one of two ways:
|
|
86
|
+
- The mouse (the default): mouse-move-file-explorer "1" -> mouse-right-click "1" -> mouse-move-file-explorer-context-menu-new-file "1" -> mouse-left-click "1" -> file-explorer-type-new-file-input "<filename>" -> file-explorer-enter-new-file-input "1". For a file inside a folder, start with mouse-move-file-explorer-folder "<folder>" and use mouse-move-file-explorer-folder-context-menu-new-file instead.
|
|
87
|
+
- The terminal, for more technical lessons: terminal-open "1" (if the terminal isn't open yet) -> terminal-type "touch <path>" -> terminal-enter "1".
|
|
88
|
+
Folders the same way: mouse-move-file-explorer-context-menu-new-folder with file-explorer-type-new-folder-input / file-explorer-enter-new-folder-input, or terminal-type "mkdir <path>".
|
|
89
|
+
- Never use file-explorer-create-file, file-explorer-create-folder or file-explorer-set-file-contents: they change files with nothing on screen causing it, so things just appear. Don't use the composite-* actions or editor-highlight-code either: they aren't supported yet and do nothing. codevideo_validate_course rejects lessons that use any of these.
|
|
90
|
+
- A file you just created with the mouse is ALREADY open in the editor \u2014 type into it immediately with editor-type; do NOT reopen it. A file made with touch is not open yet: open it as below. When a lesson continues from the previous one, the file you last edited is already the active editor.
|
|
91
|
+
- To open a DIFFERENT existing file: mouse-move-file-explorer-file "<filename>" -> file-explorer-open-file "<filename>". IMPORTANT: file-explorer-open-file's value is the FILENAME, not "1" \u2014 using "1" opens a stray editor and your typing is lost.
|
|
92
|
+
- Save with editor-save once a file's typing is done (narrating it first): compile checks and file reads see saved contents only.
|
|
93
|
+
- editor-type's value is the literal text to insert (including newlines).
|
|
94
|
+
|
|
95
|
+
NARRATION:
|
|
96
|
+
- Every lesson opens with an author-speak-before (codevideo_validate_course checks this). The first lesson frames what the course builds. Every lesson after the first opens with one sentence recapping the previous lesson (codevideo_add_lesson returns its name and description), e.g. "In the last lesson, we built an add function." Vary the wording from lesson to lesson. If the lesson starts something new, bridge instead: "Last time we built X; now we start Y."
|
|
97
|
+
- After the recap sentence you may show what it refers to: at most two stops, each an action followed by the sentence about what's now on screen. Bring an open file forward with mouse-move-editor-tab "<path>" + mouse-left-click, or open one with mouse-move-file-explorer-file "<path>" + file-explorer-open-file "<path>"; codevideo_add_lesson lists the previous lesson's files and whether they're open. Pick what this lesson builds on. Don't select code in a recap: the next editor-type would replace the selection.
|
|
98
|
+
- Use the inclusive "we" for what happens on screen: "Now we export the function." Switch to "you" for options, advice, caveats and things to try: "You can also write this as an arrow function." Never "I".
|
|
99
|
+
- End each lesson with a short summary, after its final save.
|
|
100
|
+
- Write file and code names as they appear on screen (math.ts, useState): captions show your exact words.`;
|
|
101
|
+
|
|
102
|
+
// src/prompts/instructions_to_create_lesson_actions.ts
|
|
103
|
+
var instructionsToCreateLessonActions = (additionalContext) => {
|
|
104
|
+
return `You are an expert senior software engineer and educator, and skilled in describing software concepts in a way that is easy for students to understand. You are going to create lesson actions using the CodeVideo framework, a framework that uses simple JSON to generate tutorials in a variety of formats (video, markdown, pdf, and more). This JSON array is always an array of simple \`IAction\`:
|
|
79
105
|
|
|
80
106
|
\`\`\`typescript
|
|
81
107
|
export interface IAction {
|
|
@@ -106,26 +132,53 @@ const instructionsToCreateLessonActions = (additionalContext) => {
|
|
|
106
132
|
| ExternalActions;
|
|
107
133
|
\`\`\`
|
|
108
134
|
|
|
109
|
-
Here
|
|
135
|
+
Here is every action you can choose from, with what it does and what its value takes:
|
|
110
136
|
|
|
111
|
-
\`\`\`
|
|
112
|
-
|
|
113
|
-
${AllActionStrings.map((action) => `"${action}"`).join(",\n ")}
|
|
114
|
-
];
|
|
137
|
+
\`\`\`
|
|
138
|
+
${actionReference()}
|
|
115
139
|
\`\`\`
|
|
116
140
|
|
|
117
|
-
|
|
141
|
+
Use the MCP tool \`codevideo_get_action_names\` to get the most updated list of all the actions available. You can also use the MCP tool \`codevideo_get_example_actions_array_by_keyword\` \`codevideo_get_example_lessons_by_keyword\` to get verified, accurate, and correct lessons created by humans.
|
|
118
142
|
|
|
119
|
-
|
|
143
|
+
${AUTHORING_RULES}
|
|
144
|
+
|
|
145
|
+
WORKFLOW TIP: After creating actions, you can store them using \`codevideo_set_current_actions\` and then use other tools like \`codevideo_validate_actions\`, \`codevideo_make_video_from_actions\`, \`codevideo_make_markdown_from_actions\`, etc. without needing to re-specify the actions each time. These tools will automatically retrieve the stored actions if no actions parameter is provided. Videos render as background jobs: the video tools return a jobId at once, and \`codevideo_get_render_job\` reports progress and the saved file. For a course of several lessons, use \`codevideo_instructions_to_build_course_from_scratch\` and the course tools instead of this single shared slot.
|
|
146
|
+
|
|
147
|
+
LESSON MANAGEMENT: You can also store complete lesson objects using \`codevideo_set_current_lesson\` and retrieve them with \`codevideo_get_current_lesson\`. This is useful for the FULL LESSON OBJECT approach where you need to manage complete lessons with snapshots.
|
|
120
148
|
|
|
121
|
-
|
|
149
|
+
ACTIONS vs FULL LESSON OBJECTS:
|
|
150
|
+
You have two approaches for creating lessons:
|
|
151
|
+
|
|
152
|
+
1. **ACTIONS-ONLY APPROACH**: Use when creating simple tutorials that start from a clean slate or when you only need to demonstrate specific coding steps. Use \`codevideo_make_video_from_actions\` for this approach.
|
|
153
|
+
|
|
154
|
+
2. **FULL LESSON OBJECT APPROACH**: Use when you need to:
|
|
155
|
+
- Start from an existing codebase (use \`codevideo_get_initial_lesson_state_from_local_repo\` or \`codevideo_get_initial_lesson_state_from_github_repo\`)
|
|
156
|
+
- Continue from a previous lesson's ending state (use \`codevideo_get_final_lesson_snapshot\` from a previous lesson)
|
|
157
|
+
- Have complete control over the initial and final snapshots
|
|
158
|
+
- Use \`codevideo_make_video_from_lesson\` for this approach
|
|
122
159
|
|
|
123
160
|
RECOMMENDED WORKFLOW:
|
|
124
|
-
1.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
161
|
+
1. **DECIDE YOUR APPROACH**:
|
|
162
|
+
- For simple tutorials starting from scratch: Use ACTIONS-ONLY approach
|
|
163
|
+
- For tutorials starting from existing code or continuing from previous lessons: Use FULL LESSON OBJECT approach
|
|
164
|
+
|
|
165
|
+
2. Call the \`codevideo_get_example_lesson\` tool to examine example lessons to get a feel for the codevideo actions and composition.
|
|
166
|
+
3. Use the \`codevideo_get_action_names\` tool to get the complete and most up-to-date list of actions.
|
|
167
|
+
|
|
168
|
+
4. **IF USING FULL LESSON OBJECT APPROACH**:
|
|
169
|
+
4a. If starting from an existing codebase, use \`codevideo_get_initial_lesson_state_from_local_repo\` or \`codevideo_get_initial_lesson_state_from_github_repo\`
|
|
170
|
+
4b. If continuing from a previous lesson, use \`codevideo_get_final_lesson_snapshot\` from the previous lesson's actions
|
|
171
|
+
4c. Create your actions array and build the complete lesson object with initial/final snapshots
|
|
172
|
+
4d. Optionally store the lesson using \`codevideo_set_current_lesson\` for later retrieval
|
|
173
|
+
4e. Use \`codevideo_make_video_from_lesson\` with the complete lesson object
|
|
174
|
+
|
|
175
|
+
5. **IF USING ACTIONS-ONLY APPROACH**:
|
|
176
|
+
5a. Create the actions array
|
|
177
|
+
5b. Call \`codevideo_set_current_actions\` once with the actions
|
|
178
|
+
5c. Call \`codevideo_validate_actions\` WITHOUT the actions parameter - it will use the stored actions automatically
|
|
179
|
+
5d. Call \`codevideo_make_video_from_actions\` WITHOUT the actions parameter - it will use the stored actions automatically
|
|
180
|
+
|
|
181
|
+
6. Use \`codevideo_get_current_actions\` or \`codevideo_get_current_lesson\` at any later time to inspect what's currently stored if needed
|
|
129
182
|
|
|
130
183
|
With all this in mind, you are to create a lesson about the following:
|
|
131
184
|
|
|
@@ -135,9 +188,9 @@ const instructionsToCreateLessonActions = (additionalContext) => {
|
|
|
135
188
|
`;
|
|
136
189
|
};
|
|
137
190
|
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
191
|
+
// src/prompts/instructions_to_create_lesson_video.ts
|
|
192
|
+
var instructionsToCreateLessonVideo = (additionalContext) => {
|
|
193
|
+
return `You are an expert senior software engineer and educator, and skilled in describing software concepts in a way that is easy for students to understand. You are going to create a video lesson using the CodeVideo framework, a framework that uses simple JSON to generate tutorials in a variety of formats (video, markdown, pdf, and more). The JSON array used to create video is always an array of simple type \`IAction\`:
|
|
141
194
|
|
|
142
195
|
\`\`\`typescript
|
|
143
196
|
export interface IAction {
|
|
@@ -168,23 +221,46 @@ const instructionsToCreateLessonVideo = (additionalContext) => {
|
|
|
168
221
|
| ExternalActions;
|
|
169
222
|
\`\`\`
|
|
170
223
|
|
|
171
|
-
Here
|
|
224
|
+
Here is every action you can choose from, with what it does and what its value takes:
|
|
172
225
|
|
|
173
|
-
\`\`\`
|
|
174
|
-
|
|
175
|
-
${AllActionStrings.map((action) => `"${action}"`).join(",\n ")}
|
|
176
|
-
];
|
|
226
|
+
\`\`\`
|
|
227
|
+
${actionReference()}
|
|
177
228
|
\`\`\`
|
|
178
229
|
|
|
179
230
|
When in doubt with these names, feel free to use the MCP tool \`codevideo_get_action_names\` to get the most updated list of all the actions available. You can also use the MCP tool \`codevideo_get_example_lesson\` to get verified, accurate, and correct lessons created by humans.
|
|
180
231
|
|
|
181
|
-
|
|
232
|
+
${AUTHORING_RULES}
|
|
233
|
+
|
|
234
|
+
LESSON MANAGEMENT: You can store complete lesson objects using \`codevideo_set_current_lesson\` and retrieve them with \`codevideo_get_current_lesson\`. This is particularly useful for the FULL LESSON OBJECT approach where you need to manage complete lessons with snapshots.
|
|
235
|
+
|
|
236
|
+
ACTIONS vs FULL LESSON OBJECTS - CHOOSE YOUR APPROACH:
|
|
237
|
+
You have two approaches for creating video lessons:
|
|
238
|
+
|
|
239
|
+
1. **ACTIONS-ONLY APPROACH**: Use when creating simple tutorials that start from a clean slate or when you only need to demonstrate specific coding steps. Use \`codevideo_make_video_from_actions\` for this approach.
|
|
240
|
+
|
|
241
|
+
2. **FULL LESSON OBJECT APPROACH**: Use when you need to:
|
|
242
|
+
- Start from an existing codebase (use \`codevideo_get_initial_lesson_state_from_local_repo\` or \`codevideo_get_initial_lesson_state_from_github_repo\`)
|
|
243
|
+
- Continue from a previous lesson's ending state (use \`codevideo_get_final_lesson_snapshot\` from a previous lesson)
|
|
244
|
+
- Have complete control over the initial and final snapshots
|
|
245
|
+
- Use \`codevideo_make_video_from_lesson\` for this approach
|
|
182
246
|
|
|
183
247
|
RECOMMENDED WORKFLOW:
|
|
184
|
-
1.
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
248
|
+
1. **DECIDE YOUR APPROACH**:
|
|
249
|
+
- For simple tutorials starting from scratch: Use ACTIONS-ONLY approach
|
|
250
|
+
- For tutorials starting from existing code or continuing from previous lessons: Use FULL LESSON OBJECT approach
|
|
251
|
+
|
|
252
|
+
2. **IF USING ACTIONS-ONLY APPROACH**:
|
|
253
|
+
2a. Create your actions array
|
|
254
|
+
2b. Call \`codevideo_set_current_actions\` once with the actions
|
|
255
|
+
2c. Call \`codevideo_validate_actions\` WITHOUT the actions parameter - it will use the stored actions automatically - if issues are found, fix them in the actions array and call \`codevideo_set_current_actions\` again
|
|
256
|
+
2d. Call \`codevideo_make_video_from_actions\` WITHOUT the actions parameter - it will use the stored actions automatically
|
|
257
|
+
|
|
258
|
+
3. **IF USING FULL LESSON OBJECT APPROACH**:
|
|
259
|
+
3a. If starting from an existing codebase, use \`codevideo_get_initial_lesson_state_from_local_repo\` or \`codevideo_get_initial_lesson_state_from_github_repo\`
|
|
260
|
+
3b. If continuing from a previous lesson, use \`codevideo_get_final_lesson_snapshot\` from the previous lesson's actions
|
|
261
|
+
3c. Create your actions array and build the complete lesson object with initial/final snapshots
|
|
262
|
+
3d. Optionally store the lesson using \`codevideo_set_current_lesson\` for later retrieval
|
|
263
|
+
3e. Use \`codevideo_make_video_from_lesson\` with the complete lesson object
|
|
188
264
|
|
|
189
265
|
With all this in mind, you are to create a lesson about the following:
|
|
190
266
|
|
|
@@ -194,1179 +270,3511 @@ const instructionsToCreateLessonVideo = (additionalContext) => {
|
|
|
194
270
|
`;
|
|
195
271
|
};
|
|
196
272
|
|
|
197
|
-
//
|
|
198
|
-
|
|
273
|
+
// src/constants/Constants.ts
|
|
274
|
+
var CURRENT_ACTIONS_KEY = "current_actions";
|
|
275
|
+
var CURRENT_LESSON_KEY = "current_lesson";
|
|
276
|
+
|
|
277
|
+
// src/store/db.ts
|
|
278
|
+
import fs from "node:fs";
|
|
279
|
+
import os from "node:os";
|
|
280
|
+
import path from "node:path";
|
|
281
|
+
import { createRequire } from "node:module";
|
|
282
|
+
var codevideoHome = () => {
|
|
283
|
+
const dir = path.join(os.homedir(), ".codevideo");
|
|
284
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
285
|
+
return dir;
|
|
286
|
+
};
|
|
287
|
+
var databasePath = () => path.join(codevideoHome(), "codevideo.db");
|
|
288
|
+
var sqlite;
|
|
289
|
+
var loadSqlite = () => {
|
|
290
|
+
if (!sqlite) {
|
|
291
|
+
const emitWarning = process.emitWarning;
|
|
292
|
+
process.emitWarning = ((warning, ...rest) => {
|
|
293
|
+
const message = typeof warning === "string" ? warning : warning.message;
|
|
294
|
+
if (/SQLite/.test(message)) return;
|
|
295
|
+
return emitWarning.call(process, warning, ...rest);
|
|
296
|
+
});
|
|
199
297
|
try {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if (!fs.existsSync(dbPath)) {
|
|
204
|
-
return null;
|
|
205
|
-
}
|
|
206
|
-
// Open database
|
|
207
|
-
const db = new Database(dbPath);
|
|
208
|
-
// Check if table exists
|
|
209
|
-
const tableExists = db.prepare(`
|
|
210
|
-
SELECT name FROM sqlite_master
|
|
211
|
-
WHERE type='table' AND name='key_value_store'
|
|
212
|
-
`).get();
|
|
213
|
-
if (!tableExists) {
|
|
214
|
-
db.close();
|
|
215
|
-
return null;
|
|
216
|
-
}
|
|
217
|
-
// Retrieve current_actions
|
|
218
|
-
const stmt = db.prepare(`
|
|
219
|
-
SELECT value FROM key_value_store
|
|
220
|
-
WHERE key = ?
|
|
221
|
-
`);
|
|
222
|
-
const result = stmt.get('current_actions');
|
|
223
|
-
db.close();
|
|
224
|
-
if (!result) {
|
|
225
|
-
return null;
|
|
226
|
-
}
|
|
227
|
-
// Parse JSON back to actions array
|
|
228
|
-
const actions = JSON.parse(result.value);
|
|
229
|
-
return actions;
|
|
230
|
-
}
|
|
231
|
-
catch (error) {
|
|
232
|
-
console.error('Error retrieving actions:', error);
|
|
233
|
-
return null;
|
|
298
|
+
sqlite = createRequire(import.meta.url)("node:sqlite");
|
|
299
|
+
} finally {
|
|
300
|
+
process.emitWarning = emitWarning;
|
|
234
301
|
}
|
|
302
|
+
}
|
|
303
|
+
return sqlite;
|
|
304
|
+
};
|
|
305
|
+
var openDatabase = () => {
|
|
306
|
+
const db = new (loadSqlite()).DatabaseSync(databasePath());
|
|
307
|
+
db.exec("PRAGMA busy_timeout = 5000; PRAGMA journal_mode = WAL;");
|
|
308
|
+
return db;
|
|
309
|
+
};
|
|
310
|
+
var withDatabase = (fn) => {
|
|
311
|
+
const db = openDatabase();
|
|
312
|
+
try {
|
|
313
|
+
return fn(db);
|
|
314
|
+
} finally {
|
|
315
|
+
db.close();
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
var transaction = (db, fn) => {
|
|
319
|
+
db.exec("BEGIN IMMEDIATE");
|
|
320
|
+
try {
|
|
321
|
+
const result = fn();
|
|
322
|
+
db.exec("COMMIT");
|
|
323
|
+
return result;
|
|
324
|
+
} catch (error) {
|
|
325
|
+
db.exec("ROLLBACK");
|
|
326
|
+
throw error;
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// src/store/keyValueStore.ts
|
|
331
|
+
var SCHEMA = `
|
|
332
|
+
CREATE TABLE IF NOT EXISTS key_value_store (
|
|
333
|
+
key TEXT PRIMARY KEY,
|
|
334
|
+
value TEXT NOT NULL,
|
|
335
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
336
|
+
)
|
|
337
|
+
`;
|
|
338
|
+
var setStoredValue = (key, value) => withDatabase((db) => {
|
|
339
|
+
db.exec(SCHEMA);
|
|
340
|
+
db.prepare(`INSERT OR REPLACE INTO key_value_store (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)`).run(key, value);
|
|
341
|
+
return databasePath();
|
|
342
|
+
});
|
|
343
|
+
var getStoredValue = (key) => withDatabase((db) => {
|
|
344
|
+
db.exec(SCHEMA);
|
|
345
|
+
const row = db.prepare(`SELECT value FROM key_value_store WHERE key = ?`).get(key);
|
|
346
|
+
return row ? row.value : null;
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// src/tools/get_current_actions.ts
|
|
350
|
+
var getCurrentActions = () => {
|
|
351
|
+
try {
|
|
352
|
+
const value = getStoredValue(CURRENT_ACTIONS_KEY);
|
|
353
|
+
return value ? JSON.parse(value) : null;
|
|
354
|
+
} catch (error) {
|
|
355
|
+
console.error("Error retrieving actions:", error);
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
var getCurrentActionsOrThrow = () => {
|
|
360
|
+
const actions = getCurrentActions();
|
|
361
|
+
if (!actions) {
|
|
362
|
+
throw new Error("No actions found in storage. Please use codevideo_set_current_actions first to store actions, then try again.");
|
|
363
|
+
}
|
|
364
|
+
return actions;
|
|
235
365
|
};
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
366
|
+
|
|
367
|
+
// src/prompts/instructions_to_translate_actions.ts
|
|
368
|
+
var instructionsToTranslateActions = (actionsToTranslation, targetLanguage) => {
|
|
369
|
+
let actionsToUse;
|
|
370
|
+
if (actionsToTranslation && actionsToTranslation.length > 0) {
|
|
371
|
+
actionsToUse = actionsToTranslation;
|
|
372
|
+
} else {
|
|
373
|
+
const storedActions = getCurrentActions();
|
|
374
|
+
if (!storedActions || storedActions.length === 0) {
|
|
375
|
+
return "No actions provided and no actions found in storage. Either pass actionsToTranslation parameter or use codevideo_set_current_actions first to store actions.";
|
|
241
376
|
}
|
|
242
|
-
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
377
|
+
actionsToUse = storedActions;
|
|
378
|
+
}
|
|
379
|
+
return `## \u{1F4DD} Translation Instructions for CodeVideo Action Scripts
|
|
380
|
+
|
|
381
|
+
### \u{1F4CC} Objective
|
|
382
|
+
|
|
383
|
+
You are given a JSON array representing an instructional CodeVideo script. Your task is to translate all relevant text **within the \`"value"\` fields** into the specified target language **while preserving CodeVideo execution logic**.
|
|
384
|
+
|
|
385
|
+
**WORKFLOW TIP**: Use \`codevideo_set_current_actions\` to store actions first, then call this tool without the actionsToTranslation parameter. This allows you to work with the same actions across multiple translation operations or other CodeVideo tools like \`codevideo_validate_actions\`, \`codevideo_make_video_from_actions\`, etc.
|
|
386
|
+
|
|
387
|
+
**VALIDATION TIP**: After translation, you can validate the translated actions using \`codevideo_validate_actions\` to ensure the CodeVideo structure remains intact.
|
|
388
|
+
|
|
389
|
+
---
|
|
390
|
+
|
|
391
|
+
### \u2705 What to Translate
|
|
392
|
+
Translate the following, only if found within the \`"value"\` fields:
|
|
393
|
+
|
|
394
|
+
1. **All narration steps:**
|
|
395
|
+
- \`"name": "author-speak-before"\` or \`"author-speak-after"\`
|
|
396
|
+
\u2192 Fully translate narration into fluent, natural speech in the target language.
|
|
397
|
+
|
|
398
|
+
2. **Code comments:**
|
|
399
|
+
- Lines starting with \`#\` or \`//\` or between \`/* ... */\`
|
|
400
|
+
\u2192 Translate the comment portion only, preserving syntax markers.
|
|
401
|
+
|
|
402
|
+
3. **String literals inside code:**
|
|
403
|
+
- e.g., \`print("Hello, World!")\`
|
|
404
|
+
\u2192 Translate the string inside the quotes, preserving quote marks and code structure.
|
|
405
|
+
|
|
406
|
+
4. **Terminal outputs:**
|
|
407
|
+
- \`"name": "terminal-set-output"\`
|
|
408
|
+
\u2192 Translate printed outputs line by line.
|
|
409
|
+
|
|
410
|
+
---
|
|
411
|
+
|
|
412
|
+
### \u{1F6AB} What NOT to Translate
|
|
413
|
+
Do **not** translate the following:
|
|
414
|
+
|
|
415
|
+
- \`"name"\` field values (e.g., \`"author-speak-before"\`, \`"editor-type"\`, etc.)
|
|
416
|
+
\u2192 These are CodeVideo internal commands.
|
|
417
|
+
|
|
418
|
+
- File names, identifiers, variables, or function names (e.g., \`main.py\`, \`range\`, \`print\`, \`for i in range(...)\`)
|
|
419
|
+
\u2192 Keep code functional and consistent.
|
|
420
|
+
|
|
421
|
+
- Keywords or syntax in programming code
|
|
422
|
+
\u2192 Only translate *comments* and *strings*.
|
|
423
|
+
|
|
424
|
+
---
|
|
425
|
+
|
|
426
|
+
### \u{1F4A1} Examples
|
|
427
|
+
|
|
428
|
+
#### Code Comment
|
|
429
|
+
|
|
430
|
+
\`\`\`json
|
|
431
|
+
{ "name": "editor-type", "value": "# Print 'Hello, World!' five times" }
|
|
432
|
+
\`\`\`
|
|
433
|
+
|
|
434
|
+
\u27A1 becomes:
|
|
435
|
+
|
|
436
|
+
\`\`\`json
|
|
437
|
+
{ "name": "editor-type", "value": "# Imprime '\xA1Hola, Mundo!' cinco veces" }
|
|
438
|
+
\`\`\`
|
|
439
|
+
|
|
440
|
+
#### Narration
|
|
441
|
+
\`\`\`json
|
|
442
|
+
{ "name": "author-speak-before", "value": "Let's open a terminal and run the script." }
|
|
443
|
+
\`\`\`
|
|
444
|
+
\u27A1 becomes:
|
|
445
|
+
\`\`\`json
|
|
446
|
+
{ "name": "author-speak-before", "value": "Vamos a abrir una terminal y ejecutar el script." }
|
|
447
|
+
\`\`\`
|
|
448
|
+
|
|
449
|
+
#### Terminal Output
|
|
450
|
+
\`\`\`json
|
|
451
|
+
{ "name": "terminal-set-output", "value": "Hello, World!
|
|
452
|
+
Hello, World!" }
|
|
453
|
+
\`\`\`
|
|
454
|
+
\u27A1 becomes:
|
|
455
|
+
\`\`\`json
|
|
456
|
+
{ "name": "terminal-set-output", "value": "\xA1Hola, Mundo!
|
|
457
|
+
\xA1Hola, Mundo!" }
|
|
458
|
+
\`\`\`
|
|
459
|
+
|
|
460
|
+
---
|
|
461
|
+
|
|
462
|
+
### \u{1F30D} Target Language
|
|
463
|
+
|
|
464
|
+
The target language will be specified separately. Use fluent, natural phrasing appropriate to that language's tone and style of instruction.
|
|
465
|
+
|
|
466
|
+
---
|
|
467
|
+
|
|
468
|
+
### \u{1F9E0} Tips
|
|
469
|
+
|
|
470
|
+
- Match tone to the original narration (enthusiastic, beginner-friendly).
|
|
471
|
+
- Avoid overly literal translations \u2014 clarity is more important.
|
|
472
|
+
- Maintain line breaks, indentation, and special characters exactly as they appear in code or outputs.
|
|
473
|
+
|
|
474
|
+
---
|
|
475
|
+
|
|
476
|
+
### \u2705 Output Format
|
|
477
|
+
|
|
478
|
+
Return the translated JSON with only the \`value\` fields updated. The JSON structure must remain valid and unchanged otherwise.
|
|
479
|
+
|
|
480
|
+
\`\`\`json
|
|
481
|
+
{ "name": "editor-type", "value": "# Print 'Hello, World!' five times" }
|
|
482
|
+
\`\`\`
|
|
483
|
+
\u27A1 becomes:
|
|
484
|
+
\`\`\`json
|
|
485
|
+
{ "name": "editor-type", "value": "# Imprime '\xA1Hola, Mundo!' cinco veces" }
|
|
486
|
+
\`\`\`
|
|
487
|
+
|
|
488
|
+
#### Narration
|
|
489
|
+
\`\`\`json
|
|
490
|
+
{ "name": "author-speak-before", "value": "Let's open a terminal and run the script." }
|
|
491
|
+
\`\`\`
|
|
492
|
+
\u27A1 becomes:
|
|
493
|
+
\`\`\`json
|
|
494
|
+
{ "name": "author-speak-before", "value": "Vamos a abrir una terminal y ejecutar el script." }
|
|
495
|
+
\`\`\`
|
|
496
|
+
|
|
497
|
+
#### Terminal Output
|
|
498
|
+
\`\`\`json
|
|
499
|
+
{ "name": "terminal-set-output", "value": "Hello, World!
|
|
500
|
+
Hello, World!" }
|
|
501
|
+
\`\`\`
|
|
502
|
+
\u27A1 becomes:
|
|
503
|
+
\`\`\`json
|
|
504
|
+
{ "name": "terminal-set-output", "value": "\xA1Hola, Mundo!
|
|
505
|
+
\xA1Hola, Mundo!" }
|
|
506
|
+
\`\`\`
|
|
507
|
+
|
|
508
|
+
---
|
|
509
|
+
|
|
510
|
+
### \u{1F30D} Target Language
|
|
511
|
+
|
|
512
|
+
The target language will be specified below. Use fluent, natural phrasing appropriate to that language's tone and style of instruction.
|
|
513
|
+
|
|
514
|
+
---
|
|
515
|
+
|
|
516
|
+
### \u{1F9E0} Tips
|
|
517
|
+
|
|
518
|
+
- Match tone to the original narration (enthusiastic, beginner-friendly).
|
|
519
|
+
- Avoid overly literal translations \u2014 clarity is more important.
|
|
520
|
+
- Maintain line breaks, indentation, and special characters exactly as they appear in code or outputs.
|
|
521
|
+
|
|
522
|
+
---
|
|
523
|
+
|
|
524
|
+
### \u2705 Output Format
|
|
525
|
+
Return the translated JSON with only the \`"value"\` fields updated. The JSON structure must remain valid and unchanged otherwise.
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
Considering all these instructions, please translate the following actions into ${targetLanguage}:
|
|
529
|
+
|
|
530
|
+
\`\`\`json
|
|
531
|
+
${JSON.stringify(actionsToUse, null, 4)}
|
|
532
|
+
\`\`\`
|
|
533
|
+
|
|
534
|
+
`;
|
|
323
535
|
};
|
|
324
536
|
|
|
325
|
-
|
|
326
|
-
|
|
537
|
+
// src/prompts/instructions_to_build_course_from_scratch.ts
|
|
538
|
+
var instructionsToBuildCourseFromScratch = (additionalContext) => {
|
|
539
|
+
return `You are an expert senior software engineer and educator building a COMPLETE, multi-lesson software course with the CodeVideo MCP, in the clear, small-step style of a polished screencast series: one concept per lesson, narrate before and while you type, and make sure the code actually builds at the end of every lesson that is meant to build.
|
|
540
|
+
|
|
541
|
+
CRITICAL ARCHITECTURE RULE \u2014 keep snapshots OUT of your context:
|
|
542
|
+
The course lives server-side, keyed by id. You author and read only ACTIONS, compile errors, and small targeted file reads. Do NOT fetch or pass whole snapshots or whole course/lesson objects around \u2014 that is what used to make full courses fail. Use the course store tools below; they chain each lesson's ending state into the next for you, server-side.
|
|
543
|
+
|
|
544
|
+
THE LOOP:
|
|
545
|
+
1. Outline the course first: a numbered list of lessons, each teaching ONE concept, ordered so the code grows naturally from nothing to a working result.
|
|
546
|
+
2. codevideo_create_course(name, description, primaryLanguage) -> returns courseId. Hold onto it.
|
|
547
|
+
3. For each lesson, in order:
|
|
548
|
+
a. codevideo_add_lesson(courseId, { name, description, mustCompile, continuesFromPrevious: true }).
|
|
549
|
+
- description is required: one sentence on what the lesson teaches. The next lesson's opening recap is written from it, and the reply gives you the previous lesson's.
|
|
550
|
+
- Set mustCompile: false for pure slideshow/discussion lessons or deliberate half-refactors where the code is NOT expected to compile yet.
|
|
551
|
+
- Set mustCompile: true (the default) for any lesson that ends with working code.
|
|
552
|
+
- The FINAL lesson must be mustCompile: true \u2014 all code must work by the end of the course.
|
|
553
|
+
b. Author the actions array for this lesson. Build on the previous lesson's ending state (it is already the starting state of this lesson \u2014 you don't pass it in). If you need to know what exists, use codevideo_get_lesson_file_tree, and codevideo_get_lesson_file for a specific file's current contents \u2014 never dump everything.
|
|
554
|
+
c. codevideo_set_lesson_actions(courseId, lessonId, actions). The server recomputes the lesson's final state.
|
|
555
|
+
d. If this lesson is mustCompile, codevideo_compile_lesson(courseId, lessonId). If there are errors, fix just the actions involved with codevideo_edit_lesson_actions (find indices with codevideo_get_lesson_actions; don't re-send the whole lesson) and repeat (d) until it compiles clean. (If the compile tool reports "pending", continue \u2014 compilation will be enforced once available.)
|
|
556
|
+
4. When all lessons are authored: codevideo_validate_course(courseId). It returns "done": true only when every lesson's actions are valid, every lesson follows cleanly from the last, every mustCompile lesson compiles, and the final lesson compiles clean. If "done" is false, read "reasons" and the per-lesson results, fix, and re-validate.
|
|
557
|
+
5. Use codevideo_get_course_status(courseId) at any time to see what's authored without pulling snapshots.
|
|
558
|
+
|
|
559
|
+
HARD RULE \u2014 NO VIDEO DURING AUTHORING:
|
|
560
|
+
For a full course, do NOT generate any video until codevideo_validate_course returns done: true. Rendering is the expensive final step. After validation passes, previews (codevideo_generate_course_previews: an HTML page per lesson) and the final videos (codevideo_render_course_videos: background render jobs, one lesson at a time; follow them with codevideo_list_render_jobs) are a separate, human-approved step \u2014 do not interleave them with authoring.
|
|
561
|
+
|
|
562
|
+
${AUTHORING_RULES}
|
|
563
|
+
|
|
564
|
+
Now build the course described here:
|
|
565
|
+
|
|
566
|
+
${additionalContext}
|
|
567
|
+
`;
|
|
327
568
|
};
|
|
328
569
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
x: 0,
|
|
390
|
-
y: 0
|
|
391
|
-
},
|
|
392
|
-
},
|
|
393
|
-
authorSnapshot: {
|
|
394
|
-
authors: [
|
|
395
|
-
{ currentSpeechCaption: '' }
|
|
396
|
-
]
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
};
|
|
400
|
-
// Add the lesson to the course
|
|
401
|
-
course.lessons.push(lesson);
|
|
402
|
-
// Return the updated course object
|
|
403
|
-
return JSON.stringify(course, null, 2);
|
|
404
|
-
};
|
|
405
|
-
|
|
406
|
-
const addActionsToLesson = (lesson, actions) => {
|
|
407
|
-
lesson.actions = actions;
|
|
408
|
-
return JSON.stringify(lesson, null, 2);
|
|
409
|
-
};
|
|
410
|
-
|
|
411
|
-
const getEmptyLessonSnapshot = () => {
|
|
412
|
-
const snapshot = {
|
|
413
|
-
isUnsavedChangesDialogOpen: false,
|
|
414
|
-
unsavedFileName: '',
|
|
415
|
-
fileExplorerSnapshot: {
|
|
416
|
-
isFileExplorerContextMenuOpen: false,
|
|
417
|
-
isFileContextMenuOpen: false,
|
|
418
|
-
isFolderContextMenuOpen: false,
|
|
419
|
-
isNewFileInputVisible: false,
|
|
420
|
-
isNewFolderInputVisible: false,
|
|
421
|
-
isRenameFileInputVisible: false,
|
|
422
|
-
isRenameFolderInputVisible: false,
|
|
423
|
-
newFileInputValue: '',
|
|
424
|
-
newFolderInputValue: '',
|
|
425
|
-
renameFileInputValue: '',
|
|
426
|
-
renameFolderInputValue: '',
|
|
427
|
-
originalFileBeingRenamed: '',
|
|
428
|
-
originalFolderBeingRenamed: '',
|
|
429
|
-
newFileParentPath: '',
|
|
430
|
-
newFolderParentPath: '',
|
|
431
|
-
fileStructure: {},
|
|
432
|
-
},
|
|
433
|
-
editorSnapshot: {
|
|
434
|
-
isEditorContextMenuOpen: false,
|
|
435
|
-
editors: []
|
|
570
|
+
// src/tools/get_final_lesson_snapshot.ts
|
|
571
|
+
import { VirtualIDE } from "@fullstackcraftllc/codevideo-virtual-ide";
|
|
572
|
+
var getFinalLessonSnapshot = (actions) => {
|
|
573
|
+
const actionsToUse = actions || getCurrentActionsOrThrow();
|
|
574
|
+
const virtualIDE = new VirtualIDE();
|
|
575
|
+
virtualIDE.applyActions(actionsToUse);
|
|
576
|
+
const finalSnapshot = virtualIDE.getCourseSnapshot();
|
|
577
|
+
return JSON.stringify(finalSnapshot, null, 2);
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
// src/tools/get_initial_lesson_state_from_local_repo.ts
|
|
581
|
+
import { buildFileStructureFromRootFolder } from "@fullstackcraftllc/codevideo-adapters";
|
|
582
|
+
var getInitialLessonStateFromLocalCodebase = async (repoLocation) => {
|
|
583
|
+
const { fileStructure } = await buildFileStructureFromRootFolder(repoLocation);
|
|
584
|
+
const project = {
|
|
585
|
+
id: "",
|
|
586
|
+
name: "",
|
|
587
|
+
description: "",
|
|
588
|
+
actions: [],
|
|
589
|
+
initialSnapshot: {
|
|
590
|
+
isUnsavedChangesDialogOpen: false,
|
|
591
|
+
unsavedFileName: "",
|
|
592
|
+
fileExplorerSnapshot: {
|
|
593
|
+
isFileExplorerContextMenuOpen: false,
|
|
594
|
+
isFileContextMenuOpen: false,
|
|
595
|
+
isFolderContextMenuOpen: false,
|
|
596
|
+
isNewFileInputVisible: false,
|
|
597
|
+
isNewFolderInputVisible: false,
|
|
598
|
+
isRenameFileInputVisible: false,
|
|
599
|
+
isRenameFolderInputVisible: false,
|
|
600
|
+
newFileInputValue: "",
|
|
601
|
+
newFolderInputValue: "",
|
|
602
|
+
renameFileInputValue: "",
|
|
603
|
+
renameFolderInputValue: "",
|
|
604
|
+
originalFileBeingRenamed: "",
|
|
605
|
+
originalFolderBeingRenamed: "",
|
|
606
|
+
newFileParentPath: "",
|
|
607
|
+
newFolderParentPath: "",
|
|
608
|
+
fileStructure
|
|
609
|
+
},
|
|
610
|
+
editorSnapshot: {
|
|
611
|
+
isEditorContextMenuOpen: false,
|
|
612
|
+
editors: []
|
|
613
|
+
},
|
|
614
|
+
terminalSnapshot: {
|
|
615
|
+
terminals: []
|
|
616
|
+
},
|
|
617
|
+
mouseSnapshot: {
|
|
618
|
+
location: "editor",
|
|
619
|
+
currentHoveredFileName: "",
|
|
620
|
+
currentHoveredFolderName: "",
|
|
621
|
+
currentHoveredEditorTabFileName: "",
|
|
622
|
+
x: 0,
|
|
623
|
+
y: 0,
|
|
624
|
+
timestamp: 0,
|
|
625
|
+
type: "move",
|
|
626
|
+
buttonStates: {
|
|
627
|
+
left: false,
|
|
628
|
+
right: false,
|
|
629
|
+
middle: false
|
|
436
630
|
},
|
|
437
|
-
|
|
438
|
-
|
|
631
|
+
scrollPosition: {
|
|
632
|
+
x: 0,
|
|
633
|
+
y: 0
|
|
634
|
+
}
|
|
635
|
+
},
|
|
636
|
+
authorSnapshot: {
|
|
637
|
+
authors: [
|
|
638
|
+
{ currentSpeechCaption: "" }
|
|
639
|
+
]
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
return JSON.stringify(project);
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
// src/tools/get_initial_lesson_state_from_github_repo.ts
|
|
647
|
+
import { buildFileStructureFromGitHub } from "@fullstackcraftllc/codevideo-adapters";
|
|
648
|
+
var getInitialLessonStateFromGitHubRepo = async (repoUrl) => {
|
|
649
|
+
const fileStructure = await buildFileStructureFromGitHub(repoUrl);
|
|
650
|
+
const project = {
|
|
651
|
+
id: "",
|
|
652
|
+
name: "",
|
|
653
|
+
description: "",
|
|
654
|
+
actions: [],
|
|
655
|
+
initialSnapshot: {
|
|
656
|
+
isUnsavedChangesDialogOpen: false,
|
|
657
|
+
unsavedFileName: "",
|
|
658
|
+
fileExplorerSnapshot: {
|
|
659
|
+
isFileExplorerContextMenuOpen: false,
|
|
660
|
+
isFileContextMenuOpen: false,
|
|
661
|
+
isFolderContextMenuOpen: false,
|
|
662
|
+
isNewFileInputVisible: false,
|
|
663
|
+
isNewFolderInputVisible: false,
|
|
664
|
+
isRenameFileInputVisible: false,
|
|
665
|
+
isRenameFolderInputVisible: false,
|
|
666
|
+
newFileInputValue: "",
|
|
667
|
+
newFolderInputValue: "",
|
|
668
|
+
renameFileInputValue: "",
|
|
669
|
+
renameFolderInputValue: "",
|
|
670
|
+
originalFileBeingRenamed: "",
|
|
671
|
+
originalFolderBeingRenamed: "",
|
|
672
|
+
newFileParentPath: "",
|
|
673
|
+
newFolderParentPath: "",
|
|
674
|
+
fileStructure
|
|
675
|
+
},
|
|
676
|
+
editorSnapshot: {
|
|
677
|
+
isEditorContextMenuOpen: false,
|
|
678
|
+
editors: []
|
|
679
|
+
},
|
|
680
|
+
terminalSnapshot: {
|
|
681
|
+
terminals: []
|
|
682
|
+
},
|
|
683
|
+
mouseSnapshot: {
|
|
684
|
+
location: "editor",
|
|
685
|
+
currentHoveredFileName: "",
|
|
686
|
+
currentHoveredFolderName: "",
|
|
687
|
+
currentHoveredEditorTabFileName: "",
|
|
688
|
+
x: 0,
|
|
689
|
+
y: 0,
|
|
690
|
+
timestamp: 0,
|
|
691
|
+
type: "move",
|
|
692
|
+
buttonStates: {
|
|
693
|
+
left: false,
|
|
694
|
+
right: false,
|
|
695
|
+
middle: false
|
|
439
696
|
},
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
697
|
+
scrollPosition: {
|
|
698
|
+
x: 0,
|
|
699
|
+
y: 0
|
|
700
|
+
}
|
|
701
|
+
},
|
|
702
|
+
authorSnapshot: {
|
|
703
|
+
authors: [
|
|
704
|
+
{ currentSpeechCaption: "" }
|
|
705
|
+
]
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
return JSON.stringify(project);
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
// src/tools/is_repeatable_action.ts
|
|
713
|
+
import { isRepeatableAction } from "@fullstackcraftllc/codevideo-types";
|
|
714
|
+
var isRepeatable = (action) => {
|
|
715
|
+
return isRepeatableAction(action) ? "true" : "false";
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
// src/tools/create_course_with_initial_metadata.ts
|
|
719
|
+
var createCourseWithInitialMetadata = (id, name, description, primaryLanguage) => {
|
|
720
|
+
const course = {
|
|
721
|
+
id,
|
|
722
|
+
name,
|
|
723
|
+
description,
|
|
724
|
+
primaryLanguage,
|
|
725
|
+
lessons: []
|
|
726
|
+
};
|
|
727
|
+
return JSON.stringify(course, null, 2);
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
// src/tools/add_lesson_to_course.ts
|
|
731
|
+
var addLessonToCourse = (course, id, name, description) => {
|
|
732
|
+
const lesson = {
|
|
733
|
+
id,
|
|
734
|
+
name,
|
|
735
|
+
description,
|
|
736
|
+
actions: [],
|
|
737
|
+
initialSnapshot: {
|
|
738
|
+
isUnsavedChangesDialogOpen: false,
|
|
739
|
+
unsavedFileName: "",
|
|
740
|
+
fileExplorerSnapshot: {
|
|
741
|
+
isFileExplorerContextMenuOpen: false,
|
|
742
|
+
isFileContextMenuOpen: false,
|
|
743
|
+
isFolderContextMenuOpen: false,
|
|
744
|
+
isNewFileInputVisible: false,
|
|
745
|
+
isNewFolderInputVisible: false,
|
|
746
|
+
isRenameFileInputVisible: false,
|
|
747
|
+
isRenameFolderInputVisible: false,
|
|
748
|
+
newFileInputValue: "",
|
|
749
|
+
newFolderInputValue: "",
|
|
750
|
+
renameFileInputValue: "",
|
|
751
|
+
renameFolderInputValue: "",
|
|
752
|
+
originalFileBeingRenamed: "",
|
|
753
|
+
originalFolderBeingRenamed: "",
|
|
754
|
+
newFileParentPath: "",
|
|
755
|
+
newFolderParentPath: "",
|
|
756
|
+
fileStructure: {}
|
|
757
|
+
},
|
|
758
|
+
editorSnapshot: {
|
|
759
|
+
isEditorContextMenuOpen: false,
|
|
760
|
+
editors: []
|
|
761
|
+
},
|
|
762
|
+
terminalSnapshot: {
|
|
763
|
+
terminals: []
|
|
764
|
+
},
|
|
765
|
+
mouseSnapshot: {
|
|
766
|
+
location: "editor",
|
|
767
|
+
currentHoveredFileName: "",
|
|
768
|
+
currentHoveredFolderName: "",
|
|
769
|
+
currentHoveredEditorTabFileName: "",
|
|
770
|
+
x: 0,
|
|
771
|
+
y: 0,
|
|
772
|
+
timestamp: 0,
|
|
773
|
+
type: "move",
|
|
774
|
+
buttonStates: {
|
|
775
|
+
left: false,
|
|
776
|
+
right: false,
|
|
777
|
+
middle: false
|
|
458
778
|
},
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
]
|
|
779
|
+
scrollPosition: {
|
|
780
|
+
x: 0,
|
|
781
|
+
y: 0
|
|
463
782
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
783
|
+
},
|
|
784
|
+
authorSnapshot: {
|
|
785
|
+
authors: [
|
|
786
|
+
{ currentSpeechCaption: "" }
|
|
787
|
+
]
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
};
|
|
791
|
+
course.lessons.push(lesson);
|
|
792
|
+
return JSON.stringify(course, null, 2);
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
// src/tools/add_actions_to_lesson.ts
|
|
796
|
+
var addActionsToLesson = (lesson, actions) => {
|
|
797
|
+
lesson.actions = actions;
|
|
798
|
+
return JSON.stringify(lesson, null, 2);
|
|
467
799
|
};
|
|
468
800
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
801
|
+
// src/tools/get_empty_lesson_snapshot.ts
|
|
802
|
+
var getEmptyLessonSnapshot = () => {
|
|
803
|
+
const snapshot = {
|
|
804
|
+
isUnsavedChangesDialogOpen: false,
|
|
805
|
+
unsavedFileName: "",
|
|
806
|
+
fileExplorerSnapshot: {
|
|
807
|
+
isFileExplorerContextMenuOpen: false,
|
|
808
|
+
isFileContextMenuOpen: false,
|
|
809
|
+
isFolderContextMenuOpen: false,
|
|
810
|
+
isNewFileInputVisible: false,
|
|
811
|
+
isNewFolderInputVisible: false,
|
|
812
|
+
isRenameFileInputVisible: false,
|
|
813
|
+
isRenameFolderInputVisible: false,
|
|
814
|
+
newFileInputValue: "",
|
|
815
|
+
newFolderInputValue: "",
|
|
816
|
+
renameFileInputValue: "",
|
|
817
|
+
renameFolderInputValue: "",
|
|
818
|
+
originalFileBeingRenamed: "",
|
|
819
|
+
originalFolderBeingRenamed: "",
|
|
820
|
+
newFileParentPath: "",
|
|
821
|
+
newFolderParentPath: "",
|
|
822
|
+
fileStructure: {}
|
|
823
|
+
},
|
|
824
|
+
editorSnapshot: {
|
|
825
|
+
isEditorContextMenuOpen: false,
|
|
826
|
+
editors: []
|
|
827
|
+
},
|
|
828
|
+
terminalSnapshot: {
|
|
829
|
+
terminals: []
|
|
830
|
+
},
|
|
831
|
+
mouseSnapshot: {
|
|
832
|
+
location: "editor",
|
|
833
|
+
currentHoveredFileName: "",
|
|
834
|
+
currentHoveredFolderName: "",
|
|
835
|
+
currentHoveredEditorTabFileName: "",
|
|
836
|
+
x: 0,
|
|
837
|
+
y: 0,
|
|
838
|
+
timestamp: 0,
|
|
839
|
+
type: "move",
|
|
840
|
+
buttonStates: {
|
|
841
|
+
left: false,
|
|
842
|
+
right: false,
|
|
843
|
+
middle: false
|
|
844
|
+
},
|
|
845
|
+
scrollPosition: {
|
|
846
|
+
x: 0,
|
|
847
|
+
y: 0
|
|
848
|
+
}
|
|
849
|
+
},
|
|
850
|
+
authorSnapshot: {
|
|
851
|
+
authors: [
|
|
852
|
+
{ currentSpeechCaption: "" }
|
|
853
|
+
]
|
|
476
854
|
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
855
|
+
};
|
|
856
|
+
return JSON.stringify(snapshot, null, 2);
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
// src/tools/critique_video_using_gemini.ts
|
|
860
|
+
import { GoogleGenAI } from "@google/genai";
|
|
861
|
+
import fs2 from "fs";
|
|
862
|
+
var critiqueVideoUsingGemini = async (videoPath, actions) => {
|
|
863
|
+
const actionsToUse = actions || getCurrentActionsOrThrow();
|
|
864
|
+
const GEMINI_API_KEY = process.env.GEMINI_API_KEY;
|
|
865
|
+
if (!GEMINI_API_KEY) {
|
|
866
|
+
throw new Error("GEMINI_API_KEY environment variable is not set");
|
|
867
|
+
}
|
|
868
|
+
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
|
|
869
|
+
const prompt = `You are an expert senior software engineer and educator, and skilled in describing software concepts in a way that is easy for students to understand. You are going to critique a procedurally generated software video lesson for clarity, accuracy, and correctness. The video and the lesson actions are attached. Your tasks:
|
|
480
870
|
|
|
481
871
|
1. Watch the video and read the lesson actions.
|
|
482
872
|
2. Critique the video for clarity, accuracy, and correctness. Of particular note is any audio artifacts or strange wordings. Do not suggest any new material, only critique the existing material.
|
|
483
873
|
3. Provide a summary of your critique, including any errors or inaccuracies you found, and at what action they occur at.`;
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
},
|
|
504
|
-
{
|
|
505
|
-
text: `Here are the lesson actions in JSON format:\n\n${actionsJson}`
|
|
506
|
-
}
|
|
507
|
-
]
|
|
508
|
-
}
|
|
509
|
-
]
|
|
510
|
-
});
|
|
511
|
-
// Get the response text
|
|
512
|
-
const result = response.text;
|
|
513
|
-
return result || "No critique was generated";
|
|
514
|
-
}
|
|
515
|
-
catch (error) {
|
|
516
|
-
console.error('Error during Gemini API call:', error);
|
|
517
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
518
|
-
throw new Error(`Failed to critique video using Gemini: ${errorMessage}`);
|
|
519
|
-
}
|
|
520
|
-
};
|
|
874
|
+
try {
|
|
875
|
+
const videoBuffer = fs2.readFileSync(videoPath);
|
|
876
|
+
const videoBase64 = videoBuffer.toString("base64");
|
|
877
|
+
const actionsJson = JSON.stringify(actionsToUse, null, 2);
|
|
878
|
+
const response = await ai.models.generateContent({
|
|
879
|
+
model: "gemini-2.5-pro-preview-05-06",
|
|
880
|
+
contents: [
|
|
881
|
+
{
|
|
882
|
+
role: "user",
|
|
883
|
+
parts: [
|
|
884
|
+
{ text: prompt },
|
|
885
|
+
{
|
|
886
|
+
fileData: {
|
|
887
|
+
mimeType: "video/mp4",
|
|
888
|
+
fileUri: `data:video/mp4;base64,${videoBase64}`
|
|
889
|
+
}
|
|
890
|
+
},
|
|
891
|
+
{
|
|
892
|
+
text: `Here are the lesson actions in JSON format:
|
|
521
893
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
// Create database directory if it doesn't exist
|
|
526
|
-
const homeDir = os.homedir();
|
|
527
|
-
const dbDir = path.join(homeDir, '.codevideo');
|
|
528
|
-
if (!fs.existsSync(dbDir)) {
|
|
529
|
-
fs.mkdirSync(dbDir, { recursive: true });
|
|
894
|
+
${actionsJson}`
|
|
895
|
+
}
|
|
896
|
+
]
|
|
530
897
|
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
)
|
|
541
|
-
`);
|
|
542
|
-
// Prepare statement to insert or replace the current_actions
|
|
543
|
-
const stmt = db.prepare(`
|
|
544
|
-
INSERT OR REPLACE INTO key_value_store (key, value, updated_at)
|
|
545
|
-
VALUES (?, ?, CURRENT_TIMESTAMP)
|
|
546
|
-
`);
|
|
547
|
-
// Convert actions to JSON string and store
|
|
548
|
-
const actionsJson = JSON.stringify(actions);
|
|
549
|
-
stmt.run('current_actions', actionsJson);
|
|
550
|
-
db.close();
|
|
551
|
-
return `Successfully stored ${actions.length} actions in local database at ${dbPath}`;
|
|
552
|
-
}
|
|
553
|
-
catch (error) {
|
|
554
|
-
return `Error storing actions: ${error.message}`;
|
|
555
|
-
}
|
|
898
|
+
]
|
|
899
|
+
});
|
|
900
|
+
const result = response.text;
|
|
901
|
+
return result || "No critique was generated";
|
|
902
|
+
} catch (error) {
|
|
903
|
+
console.error("Error during Gemini API call:", error);
|
|
904
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
905
|
+
throw new Error(`Failed to critique video using Gemini: ${errorMessage}`);
|
|
906
|
+
}
|
|
556
907
|
};
|
|
557
908
|
|
|
558
|
-
//
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
const
|
|
562
|
-
return
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
const makeHTMLWebPage = async (actions) => {
|
|
567
|
-
// Use provided actions or retrieve from storage
|
|
568
|
-
const actionsToUse = actions || getCurrentActionsOrThrow();
|
|
569
|
-
// Generate the HTML content
|
|
570
|
-
const htmlContent = await generateHtmlStringFromActions(actionsToUse);
|
|
571
|
-
// Create output file on desktop with timestamp
|
|
572
|
-
const homeDir = os.homedir();
|
|
573
|
-
const desktopDir = path.join(homeDir, 'Desktop');
|
|
574
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
575
|
-
const outputFile = path.join(desktopDir, `codevideo-${timestamp}.html`);
|
|
576
|
-
// Write HTML to file
|
|
577
|
-
fs.writeFileSync(outputFile, htmlContent, 'utf8');
|
|
578
|
-
// Open the HTML file in the default browser
|
|
579
|
-
const openChild = spawn('open', [outputFile], {
|
|
580
|
-
detached: true,
|
|
581
|
-
stdio: 'ignore'
|
|
582
|
-
});
|
|
583
|
-
openChild.unref();
|
|
584
|
-
// Return the HTML content
|
|
585
|
-
return htmlContent;
|
|
909
|
+
// src/tools/set_current_actions.ts
|
|
910
|
+
var setCurrentActions = (actions) => {
|
|
911
|
+
try {
|
|
912
|
+
const dbPath = setStoredValue(CURRENT_ACTIONS_KEY, JSON.stringify(actions));
|
|
913
|
+
return `Successfully stored ${actions.length} actions in local database at ${dbPath}`;
|
|
914
|
+
} catch (error) {
|
|
915
|
+
return `Error storing actions: ${error.message}`;
|
|
916
|
+
}
|
|
586
917
|
};
|
|
587
918
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
919
|
+
// src/tools/set_current_lesson.ts
|
|
920
|
+
var setCurrentLesson = (lesson) => {
|
|
921
|
+
try {
|
|
922
|
+
const dbPath = setStoredValue(CURRENT_LESSON_KEY, JSON.stringify(lesson));
|
|
923
|
+
return `Successfully stored lesson "${lesson.name}" (ID: ${lesson.id}) in local database at ${dbPath}`;
|
|
924
|
+
} catch (error) {
|
|
925
|
+
return `Error storing lesson: ${error.message}`;
|
|
926
|
+
}
|
|
927
|
+
};
|
|
928
|
+
|
|
929
|
+
// src/tools/get_current_lesson.ts
|
|
930
|
+
var getCurrentLesson = () => {
|
|
931
|
+
try {
|
|
932
|
+
const value = getStoredValue(CURRENT_LESSON_KEY);
|
|
933
|
+
return value ? JSON.parse(value) : null;
|
|
934
|
+
} catch (error) {
|
|
935
|
+
console.error("Error retrieving lesson:", error);
|
|
936
|
+
return null;
|
|
937
|
+
}
|
|
938
|
+
};
|
|
939
|
+
var getCurrentLessonOrThrow = () => {
|
|
940
|
+
const lesson = getCurrentLesson();
|
|
941
|
+
if (!lesson) {
|
|
942
|
+
throw new Error("No lesson found in storage. Please use codevideo_set_current_lesson first to store a lesson, then try again.");
|
|
943
|
+
}
|
|
944
|
+
return lesson;
|
|
945
|
+
};
|
|
946
|
+
|
|
947
|
+
// src/tools/make_markdown_from_actions.ts
|
|
948
|
+
import { generateMarkdownStringFromActions } from "@fullstackcraftllc/codevideo-exporters";
|
|
949
|
+
var makeMarkdownFromActions = (actions) => {
|
|
950
|
+
const actionsToUse = actions || getCurrentActionsOrThrow();
|
|
951
|
+
return generateMarkdownStringFromActions(actionsToUse);
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
// src/tools/make_markdown_from_lesson.ts
|
|
955
|
+
import { generateMarkdownStringFromActions as generateMarkdownStringFromActions2 } from "@fullstackcraftllc/codevideo-exporters";
|
|
956
|
+
var makeMarkdownFromLesson = (lesson) => {
|
|
957
|
+
const lessonToUse = lesson || getCurrentLesson();
|
|
958
|
+
if (!lessonToUse) {
|
|
959
|
+
throw new Error("No lesson provided and no current lesson found in storage");
|
|
960
|
+
}
|
|
961
|
+
return generateMarkdownStringFromActions2(lessonToUse.actions);
|
|
962
|
+
};
|
|
963
|
+
|
|
964
|
+
// src/tools/make_html_web_page.ts
|
|
965
|
+
import os2 from "os";
|
|
966
|
+
import path2 from "path";
|
|
967
|
+
import fs3 from "fs";
|
|
968
|
+
import { spawn } from "child_process";
|
|
969
|
+
import { generateHtmlStringFromActions } from "@fullstackcraftllc/codevideo-exporters";
|
|
970
|
+
var makeHTMLWebPage = async (actions) => {
|
|
971
|
+
const actionsToUse = actions || getCurrentActionsOrThrow();
|
|
972
|
+
const htmlContent = await generateHtmlStringFromActions(actionsToUse, {});
|
|
973
|
+
const homeDir = os2.homedir();
|
|
974
|
+
const desktopDir = path2.join(homeDir, "Desktop");
|
|
975
|
+
const timestamp2 = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
976
|
+
const outputFile = path2.join(desktopDir, `codevideo-${timestamp2}.html`);
|
|
977
|
+
fs3.writeFileSync(outputFile, htmlContent, "utf8");
|
|
978
|
+
const openChild = spawn("open", [outputFile], {
|
|
979
|
+
detached: true,
|
|
980
|
+
stdio: "ignore"
|
|
981
|
+
});
|
|
982
|
+
openChild.unref();
|
|
983
|
+
return htmlContent;
|
|
984
|
+
};
|
|
985
|
+
|
|
986
|
+
// src/render/queue.ts
|
|
987
|
+
import fs6 from "node:fs";
|
|
988
|
+
import os3 from "node:os";
|
|
989
|
+
import path6 from "node:path";
|
|
990
|
+
import { estimateVideoDurationInSeconds } from "@fullstackcraftllc/codevideo-types";
|
|
991
|
+
|
|
992
|
+
// src/render/jobs.ts
|
|
993
|
+
import fs4 from "node:fs";
|
|
994
|
+
import path4 from "node:path";
|
|
995
|
+
import { randomUUID } from "node:crypto";
|
|
996
|
+
|
|
997
|
+
// src/render/cli.ts
|
|
998
|
+
import path3 from "node:path";
|
|
999
|
+
|
|
1000
|
+
// src/render/options.ts
|
|
1001
|
+
import { z } from "zod";
|
|
1002
|
+
var RenderOptionsSchema = z.object({
|
|
1003
|
+
orientation: z.enum(["landscape", "portrait"]).optional().describe("Video orientation; portrait renders 1080x1920. For portrait, usually also set isFileExplorerVisible, isEditorTabsVisible, isMinimapVisible and isMouseVisible to false."),
|
|
1004
|
+
theme: z.enum(["light", "dark"]).optional(),
|
|
1005
|
+
withCaptions: z.boolean().optional(),
|
|
1006
|
+
fontSizePx: z.number().positive().optional(),
|
|
1007
|
+
fileExplorerWidth: z.number().positive().optional(),
|
|
1008
|
+
terminalHeight: z.number().positive().optional(),
|
|
1009
|
+
mouseColor: z.string().min(1).optional(),
|
|
1010
|
+
isFileExplorerVisible: z.boolean().optional(),
|
|
1011
|
+
isTerminalVisible: z.boolean().optional(),
|
|
1012
|
+
isEditorTabsVisible: z.boolean().optional(),
|
|
1013
|
+
isMinimapVisible: z.boolean().optional(),
|
|
1014
|
+
isMouseVisible: z.boolean().optional().describe("Draw the mouse cursor. Mouse actions keep their timing and effects when it's hidden."),
|
|
1015
|
+
wordWrap: z.enum(["on", "off"]).optional(),
|
|
1016
|
+
keyboardTypingPauseMs: z.number().int().positive().optional(),
|
|
1017
|
+
standardPauseMs: z.number().int().positive().optional(),
|
|
1018
|
+
longPauseMs: z.number().int().positive().optional(),
|
|
1019
|
+
defaultLanguage: z.string().min(1).optional(),
|
|
1020
|
+
voiceId: z.string().min(1).optional().describe("ElevenLabs voice ID for the narration."),
|
|
1021
|
+
ttsProvider: z.enum(["elevenlabs", "kokoro"]).optional(),
|
|
1022
|
+
renderMode: z.enum(["realtime", "deterministic"]).optional().describe("realtime (default) records the browser as it plays; deterministic renders frame by frame."),
|
|
1023
|
+
renderSeed: z.string().min(1).optional().describe("Deterministic renders only: a seed for reproducible output."),
|
|
1024
|
+
renderWorkers: z.number().int().min(1).max(32).optional().describe("Deterministic renders only: browser workers, 1-32.")
|
|
1025
|
+
}).strict().refine((o) => o.renderSeed === void 0 && o.renderWorkers === void 0 || o.renderMode === "deterministic", {
|
|
1026
|
+
message: 'renderSeed and renderWorkers need renderMode: "deterministic"'
|
|
1027
|
+
});
|
|
1028
|
+
var IDE_PROP_KEYS = [
|
|
1029
|
+
"theme",
|
|
1030
|
+
"withCaptions",
|
|
1031
|
+
"fontSizePx",
|
|
1032
|
+
"fileExplorerWidth",
|
|
1033
|
+
"terminalHeight",
|
|
1034
|
+
"mouseColor",
|
|
1035
|
+
"isFileExplorerVisible",
|
|
1036
|
+
"isTerminalVisible",
|
|
1037
|
+
"isEditorTabsVisible",
|
|
1038
|
+
"isMinimapVisible",
|
|
1039
|
+
"isMouseVisible",
|
|
1040
|
+
"wordWrap",
|
|
1041
|
+
"keyboardTypingPauseMs",
|
|
1042
|
+
"standardPauseMs",
|
|
1043
|
+
"longPauseMs",
|
|
1044
|
+
"defaultLanguage"
|
|
1045
|
+
];
|
|
1046
|
+
var RENDER_PAGE_PAUSES = { keyboardTypingPauseMs: 40, standardPauseMs: 200 };
|
|
1047
|
+
var estimatePauses = (options = {}) => ({
|
|
1048
|
+
keyboardTypingPauseMs: options.keyboardTypingPauseMs ?? RENDER_PAGE_PAUSES.keyboardTypingPauseMs,
|
|
1049
|
+
standardPauseMs: options.standardPauseMs ?? RENDER_PAGE_PAUSES.standardPauseMs
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
// src/render/cli.ts
|
|
1053
|
+
var maxProjectArgumentBytes = (platform = process.platform) => platform === "win32" ? 3e4 : 120 * 1024;
|
|
1054
|
+
var ideConfigFor = (options, defaultLanguage) => {
|
|
1055
|
+
const props = {};
|
|
1056
|
+
for (const key of IDE_PROP_KEYS) {
|
|
1057
|
+
if (options[key] !== void 0) props[key] = options[key];
|
|
1058
|
+
}
|
|
1059
|
+
if (Object.keys(props).length === 0) return null;
|
|
1060
|
+
return { theme: "dark", defaultLanguage, withCaptions: true, ...props };
|
|
1061
|
+
};
|
|
1062
|
+
var cliArgs = (input) => {
|
|
1063
|
+
const args = ["-p", input.projectJson, "-o", input.outputPath];
|
|
1064
|
+
if (input.configPath) args.push("-c", input.configPath);
|
|
1065
|
+
if (input.options.orientation === "portrait") args.push("-n", "portrait");
|
|
1066
|
+
if (input.options.renderMode) args.push("--render-mode", input.options.renderMode);
|
|
1067
|
+
if (input.options.renderSeed) args.push("--render-seed", input.options.renderSeed);
|
|
1068
|
+
if (input.options.renderWorkers) args.push("--render-workers", String(input.options.renderWorkers));
|
|
1069
|
+
if (input.open) args.push("--open");
|
|
1070
|
+
return args;
|
|
1071
|
+
};
|
|
1072
|
+
var cliEnv = (base, options, nodePath = process.execPath) => {
|
|
1073
|
+
const env = { ...base };
|
|
1074
|
+
const pathKey = Object.keys(env).find((key) => key.toUpperCase() === "PATH") ?? "PATH";
|
|
1075
|
+
env[pathKey] = [path3.dirname(nodePath), env[pathKey]].filter(Boolean).join(path3.delimiter);
|
|
1076
|
+
if (options.voiceId) env.ELEVEN_LABS_VOICE_ID = options.voiceId;
|
|
1077
|
+
if (options.ttsProvider) env.CODEVIDEO_TTS_PROVIDER = options.ttsProvider;
|
|
1078
|
+
return env;
|
|
1079
|
+
};
|
|
1080
|
+
var PROGRESS = /^\[\s*\d+%\] /;
|
|
1081
|
+
var ERROR = /^error: (.+)$/;
|
|
1082
|
+
var SAVED = /^✅ CodeVideo successfully generated and saved to (.+)$/;
|
|
1083
|
+
var parseCliLine = (line) => {
|
|
1084
|
+
const text = line.trimEnd();
|
|
1085
|
+
if (PROGRESS.test(text)) return { kind: "progress", text };
|
|
1086
|
+
const error = ERROR.exec(text);
|
|
1087
|
+
if (error) return { kind: "error", text: error[1] };
|
|
1088
|
+
const saved = SAVED.exec(text);
|
|
1089
|
+
if (saved) return { kind: "saved", path: saved[1] };
|
|
1090
|
+
return { kind: "other" };
|
|
1091
|
+
};
|
|
1092
|
+
|
|
1093
|
+
// src/render/jobs.ts
|
|
1094
|
+
var SCHEMA2 = `
|
|
1095
|
+
CREATE TABLE IF NOT EXISTS render_jobs (
|
|
1096
|
+
id TEXT PRIMARY KEY,
|
|
1097
|
+
kind TEXT NOT NULL,
|
|
1098
|
+
course_id TEXT,
|
|
1099
|
+
lesson_id TEXT,
|
|
1100
|
+
label TEXT NOT NULL DEFAULT '',
|
|
1101
|
+
output_path TEXT NOT NULL,
|
|
1102
|
+
options TEXT NOT NULL DEFAULT '{}',
|
|
1103
|
+
open INTEGER NOT NULL DEFAULT 0,
|
|
1104
|
+
status TEXT NOT NULL,
|
|
1105
|
+
pid INTEGER,
|
|
1106
|
+
progress TEXT,
|
|
1107
|
+
error TEXT,
|
|
1108
|
+
cancel_requested INTEGER NOT NULL DEFAULT 0,
|
|
1109
|
+
created_at TEXT NOT NULL,
|
|
1110
|
+
started_at TEXT,
|
|
1111
|
+
finished_at TEXT
|
|
1112
|
+
);
|
|
1113
|
+
CREATE INDEX IF NOT EXISTS idx_render_jobs_status ON render_jobs(status);
|
|
1114
|
+
CREATE TABLE IF NOT EXISTS render_worker (
|
|
1115
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
1116
|
+
pid INTEGER NOT NULL,
|
|
1117
|
+
heartbeat_at INTEGER NOT NULL
|
|
1118
|
+
);
|
|
1119
|
+
`;
|
|
1120
|
+
var WORKER_STALE_MS = 1e4;
|
|
1121
|
+
var withJobs = (fn) => withDatabase((db) => {
|
|
1122
|
+
db.exec(SCHEMA2);
|
|
1123
|
+
return fn(db);
|
|
1124
|
+
});
|
|
1125
|
+
var jobDir = (id) => path4.join(codevideoHome(), "jobs", id);
|
|
1126
|
+
var toJob = (row) => ({
|
|
1127
|
+
id: row.id,
|
|
1128
|
+
kind: row.kind,
|
|
1129
|
+
courseId: row.course_id,
|
|
1130
|
+
lessonId: row.lesson_id,
|
|
1131
|
+
label: row.label,
|
|
1132
|
+
outputPath: row.output_path,
|
|
1133
|
+
options: JSON.parse(row.options),
|
|
1134
|
+
open: row.open === 1,
|
|
1135
|
+
status: row.status,
|
|
1136
|
+
pid: row.pid,
|
|
1137
|
+
progress: row.progress,
|
|
1138
|
+
error: row.error,
|
|
1139
|
+
cancelRequested: row.cancel_requested === 1,
|
|
1140
|
+
createdAt: row.created_at,
|
|
1141
|
+
startedAt: row.started_at,
|
|
1142
|
+
finishedAt: row.finished_at
|
|
1143
|
+
});
|
|
1144
|
+
var now = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
1145
|
+
var enqueueJobs = (specs) => {
|
|
1146
|
+
const prepared = specs.map((spec) => {
|
|
1147
|
+
const projectJson = JSON.stringify(spec.project);
|
|
1148
|
+
const bytes = Buffer.byteLength(projectJson);
|
|
1149
|
+
if (bytes > maxProjectArgumentBytes()) {
|
|
1150
|
+
throw new Error(
|
|
1151
|
+
`"${spec.label}" is ${Math.ceil(bytes / 1024)} KiB as JSON, more than the ${Math.floor(maxProjectArgumentBytes() / 1024)} KiB the CLI can take on its command line here. Split it into smaller lessons.`
|
|
1152
|
+
);
|
|
607
1153
|
}
|
|
608
|
-
return
|
|
1154
|
+
return { spec, projectJson, id: randomUUID() };
|
|
1155
|
+
});
|
|
1156
|
+
for (const { spec, projectJson, id } of prepared) {
|
|
1157
|
+
const dir = jobDir(id);
|
|
1158
|
+
fs4.mkdirSync(dir, { recursive: true });
|
|
1159
|
+
fs4.writeFileSync(path4.join(dir, "project.json"), projectJson);
|
|
1160
|
+
const config = ideConfigFor(spec.options, spec.defaultLanguage);
|
|
1161
|
+
if (config) fs4.writeFileSync(path4.join(dir, "config.json"), JSON.stringify(config, null, 2));
|
|
1162
|
+
}
|
|
1163
|
+
return withJobs(
|
|
1164
|
+
(db) => transaction(db, () => {
|
|
1165
|
+
const insert = db.prepare(`
|
|
1166
|
+
INSERT INTO render_jobs (id, kind, course_id, lesson_id, label, output_path, options, open, status, created_at)
|
|
1167
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?)
|
|
1168
|
+
`);
|
|
1169
|
+
return prepared.map(({ spec, id }) => {
|
|
1170
|
+
insert.run(id, spec.kind, spec.courseId ?? null, spec.lessonId ?? null, spec.label, spec.outputPath, JSON.stringify(spec.options), spec.open ? 1 : 0, now());
|
|
1171
|
+
return toJob(db.prepare(`SELECT * FROM render_jobs WHERE id = ?`).get(id));
|
|
1172
|
+
});
|
|
1173
|
+
})
|
|
1174
|
+
);
|
|
1175
|
+
};
|
|
1176
|
+
var getJob = (id) => withJobs((db) => {
|
|
1177
|
+
const row = db.prepare(`SELECT * FROM render_jobs WHERE id = ?`).get(id);
|
|
1178
|
+
return row ? toJob(row) : null;
|
|
1179
|
+
});
|
|
1180
|
+
var listJobs = (filter = {}) => withJobs((db) => {
|
|
1181
|
+
const where = [];
|
|
1182
|
+
const params = [];
|
|
1183
|
+
if (filter.courseId) {
|
|
1184
|
+
where.push("course_id = ?");
|
|
1185
|
+
params.push(filter.courseId);
|
|
1186
|
+
}
|
|
1187
|
+
if (filter.status) {
|
|
1188
|
+
where.push("status = ?");
|
|
1189
|
+
params.push(filter.status);
|
|
1190
|
+
}
|
|
1191
|
+
const sql = `SELECT * FROM render_jobs ${where.length ? `WHERE ${where.join(" AND ")}` : ""} ORDER BY rowid DESC LIMIT ?`;
|
|
1192
|
+
return db.prepare(sql).all(...params, filter.limit ?? 20).map(toJob);
|
|
1193
|
+
});
|
|
1194
|
+
var queuePosition = (id) => withJobs((db) => {
|
|
1195
|
+
const row = db.prepare(`SELECT rowid AS seq, status FROM render_jobs WHERE id = ?`).get(id);
|
|
1196
|
+
if (!row || row.status !== "queued") return null;
|
|
1197
|
+
const ahead = db.prepare(`SELECT COUNT(*) AS n FROM render_jobs WHERE status = 'queued' AND rowid < ?`).get(row.seq);
|
|
1198
|
+
return ahead.n;
|
|
1199
|
+
});
|
|
1200
|
+
var requestCancel = (target) => withJobs(
|
|
1201
|
+
(db) => transaction(db, () => {
|
|
1202
|
+
const [column, value] = target.jobId ? ["id", target.jobId] : ["course_id", target.courseId ?? ""];
|
|
1203
|
+
const cancelled = db.prepare(`UPDATE render_jobs SET status = 'cancelled', finished_at = ? WHERE ${column} = ? AND status = 'queued'`).run(now(), value).changes;
|
|
1204
|
+
const stopping = db.prepare(`UPDATE render_jobs SET cancel_requested = 1 WHERE ${column} = ? AND status = 'running'`).run(value).changes;
|
|
1205
|
+
return { cancelled: Number(cancelled), stopping: Number(stopping) };
|
|
1206
|
+
})
|
|
1207
|
+
);
|
|
1208
|
+
var claimNextJob = () => withJobs(
|
|
1209
|
+
(db) => transaction(db, () => {
|
|
1210
|
+
const row = db.prepare(`SELECT * FROM render_jobs WHERE status = 'queued' ORDER BY rowid LIMIT 1`).get();
|
|
1211
|
+
if (!row) return null;
|
|
1212
|
+
db.prepare(`UPDATE render_jobs SET status = 'running', started_at = ? WHERE id = ?`).run(now(), row.id);
|
|
1213
|
+
return toJob({ ...row, status: "running" });
|
|
1214
|
+
})
|
|
1215
|
+
);
|
|
1216
|
+
var hasQueuedJobs = () => withJobs((db) => !!db.prepare(`SELECT 1 FROM render_jobs WHERE status = 'queued' LIMIT 1`).get());
|
|
1217
|
+
var setJobPid = (id, pid) => withJobs((db) => {
|
|
1218
|
+
db.prepare(`UPDATE render_jobs SET pid = ? WHERE id = ?`).run(pid, id);
|
|
1219
|
+
});
|
|
1220
|
+
var setJobProgress = (id, progress) => withJobs((db) => {
|
|
1221
|
+
db.prepare(`UPDATE render_jobs SET progress = ? WHERE id = ?`).run(progress, id);
|
|
1222
|
+
});
|
|
1223
|
+
var isCancelRequested = (id) => withJobs((db) => !!db.prepare(`SELECT 1 FROM render_jobs WHERE id = ? AND cancel_requested = 1`).get(id));
|
|
1224
|
+
var finishJob = (id, status, error) => withJobs((db) => {
|
|
1225
|
+
db.prepare(`UPDATE render_jobs SET status = ?, error = ?, finished_at = ? WHERE id = ?`).run(status, error, now(), id);
|
|
1226
|
+
});
|
|
1227
|
+
var isProcessAlive = (pid) => {
|
|
1228
|
+
try {
|
|
1229
|
+
process.kill(pid, 0);
|
|
1230
|
+
return true;
|
|
1231
|
+
} catch (error) {
|
|
1232
|
+
return error.code === "EPERM";
|
|
1233
|
+
}
|
|
1234
|
+
};
|
|
1235
|
+
var liveWorker = () => withJobs((db) => {
|
|
1236
|
+
const row = db.prepare(`SELECT pid, heartbeat_at FROM render_worker WHERE id = 1`).get();
|
|
1237
|
+
if (!row || Date.now() - row.heartbeat_at > WORKER_STALE_MS || !isProcessAlive(row.pid)) return null;
|
|
1238
|
+
return { pid: row.pid };
|
|
1239
|
+
});
|
|
1240
|
+
var acquireWorkerLock = (pid) => withJobs(
|
|
1241
|
+
(db) => transaction(db, () => {
|
|
1242
|
+
const row = db.prepare(`SELECT pid, heartbeat_at FROM render_worker WHERE id = 1`).get();
|
|
1243
|
+
if (row && row.pid !== pid && Date.now() - row.heartbeat_at <= WORKER_STALE_MS && isProcessAlive(row.pid)) return false;
|
|
1244
|
+
db.prepare(`INSERT OR REPLACE INTO render_worker (id, pid, heartbeat_at) VALUES (1, ?, ?)`).run(pid, Date.now());
|
|
1245
|
+
return true;
|
|
1246
|
+
})
|
|
1247
|
+
);
|
|
1248
|
+
var workerHeartbeat = (pid) => withJobs((db) => {
|
|
1249
|
+
db.prepare(`UPDATE render_worker SET heartbeat_at = ? WHERE id = 1 AND pid = ?`).run(Date.now(), pid);
|
|
1250
|
+
});
|
|
1251
|
+
var releaseWorkerLock = (pid) => withJobs((db) => {
|
|
1252
|
+
db.prepare(`DELETE FROM render_worker WHERE id = 1 AND pid = ?`).run(pid);
|
|
1253
|
+
});
|
|
1254
|
+
var markOrphanedJobsInterrupted = () => withJobs(
|
|
1255
|
+
(db) => Number(
|
|
1256
|
+
db.prepare(`UPDATE render_jobs SET status = 'interrupted', error = 'The render worker stopped before this job finished.', finished_at = ? WHERE status = 'running'`).run(now()).changes
|
|
1257
|
+
)
|
|
1258
|
+
);
|
|
1259
|
+
|
|
1260
|
+
// src/render/worker.ts
|
|
1261
|
+
import fs5 from "node:fs";
|
|
1262
|
+
import path5 from "node:path";
|
|
1263
|
+
import { spawn as spawn2, spawnSync } from "node:child_process";
|
|
1264
|
+
|
|
1265
|
+
// src/utils/getBinaryPath.ts
|
|
1266
|
+
import {
|
|
1267
|
+
resolveCodeVideoCliRuntime
|
|
1268
|
+
} from "@fullstackcraftllc/codevideo-cli";
|
|
1269
|
+
function getCodeVideoCliRuntime() {
|
|
1270
|
+
return resolveCodeVideoCliRuntime();
|
|
609
1271
|
}
|
|
610
1272
|
|
|
611
|
-
|
|
1273
|
+
// src/render/worker.ts
|
|
1274
|
+
var HEARTBEAT_MS = 2e3;
|
|
1275
|
+
var CANCEL_POLL_MS = 500;
|
|
1276
|
+
var KILL_GRACE_MS = 1e4;
|
|
1277
|
+
var log = (message) => {
|
|
1278
|
+
try {
|
|
1279
|
+
fs5.appendFileSync(path5.join(codevideoHome(), "jobs", "worker.log"), `${(/* @__PURE__ */ new Date()).toISOString()} [${process.pid}] ${message}
|
|
1280
|
+
`);
|
|
1281
|
+
} catch {
|
|
1282
|
+
}
|
|
1283
|
+
};
|
|
1284
|
+
var quietly = (what, fn) => {
|
|
1285
|
+
try {
|
|
1286
|
+
fn();
|
|
1287
|
+
} catch (error) {
|
|
1288
|
+
log(`${what} failed: ${error.message}`);
|
|
1289
|
+
}
|
|
1290
|
+
};
|
|
1291
|
+
var ensureWorker = () => {
|
|
1292
|
+
if (liveWorker()) return;
|
|
1293
|
+
const orphaned = markOrphanedJobsInterrupted();
|
|
1294
|
+
if (orphaned > 0) log(`marked ${orphaned} job(s) left by a stopped worker as interrupted`);
|
|
1295
|
+
const entry = process.argv[1];
|
|
1296
|
+
if (!entry) throw new Error("Can't start the render worker: the MCP's entry script is unknown.");
|
|
1297
|
+
const child = spawn2(process.execPath, [...process.execArgv, entry, "--render-worker"], {
|
|
1298
|
+
detached: true,
|
|
1299
|
+
stdio: "ignore",
|
|
1300
|
+
windowsHide: true,
|
|
1301
|
+
env: process.env
|
|
1302
|
+
});
|
|
1303
|
+
child.unref();
|
|
1304
|
+
};
|
|
1305
|
+
var runWorker = async () => {
|
|
1306
|
+
fs5.mkdirSync(path5.join(codevideoHome(), "jobs"), { recursive: true });
|
|
1307
|
+
while (acquireWorkerLock(process.pid)) {
|
|
1308
|
+
log("worker started");
|
|
1309
|
+
const heartbeat = setInterval(() => quietly("heartbeat", () => workerHeartbeat(process.pid)), HEARTBEAT_MS);
|
|
612
1310
|
try {
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
const homeDir = os.homedir();
|
|
620
|
-
const desktopDir = path.join(homeDir, 'Desktop');
|
|
621
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
622
|
-
const outputFile = path.join(desktopDir, `codevideo-${timestamp}.mp4`);
|
|
623
|
-
// Fire-and-forget: Start the video generation process in the background
|
|
624
|
-
const child = spawn(binaryPath, ['make-video'], {
|
|
625
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
626
|
-
cwd: process.cwd()
|
|
627
|
-
});
|
|
628
|
-
// Unreference the child process, allowing the parent to exit independently
|
|
629
|
-
child.unref();
|
|
630
|
-
// Return immediately with status message
|
|
631
|
-
return {
|
|
632
|
-
outputPath: outputFile,
|
|
633
|
-
stdout: `Video generation job with ${actionsToUse.length} actions started in the background via the codevideo-cli command. MP4 file path: ${outputFile}. It should open automatically as soon as it is done being rendered.`,
|
|
634
|
-
stderr: ''
|
|
635
|
-
};
|
|
636
|
-
}
|
|
637
|
-
catch (error) {
|
|
638
|
-
console.error('Error starting video generation:', error);
|
|
639
|
-
return {
|
|
640
|
-
outputPath: '',
|
|
641
|
-
stdout: '',
|
|
642
|
-
stderr: `Failed to start video generation: ${error.message}`,
|
|
643
|
-
error
|
|
644
|
-
};
|
|
1311
|
+
for (let job = claimNextJob(); job; job = claimNextJob()) {
|
|
1312
|
+
await runJob(job);
|
|
1313
|
+
}
|
|
1314
|
+
} finally {
|
|
1315
|
+
clearInterval(heartbeat);
|
|
1316
|
+
releaseWorkerLock(process.pid);
|
|
645
1317
|
}
|
|
1318
|
+
if (!hasQueuedJobs()) break;
|
|
1319
|
+
}
|
|
1320
|
+
log("worker exiting: queue empty");
|
|
646
1321
|
};
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
1322
|
+
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1323
|
+
var groupAlive = (pgid) => {
|
|
1324
|
+
try {
|
|
1325
|
+
process.kill(-pgid, 0);
|
|
1326
|
+
return true;
|
|
1327
|
+
} catch (error) {
|
|
1328
|
+
return error.code === "EPERM";
|
|
1329
|
+
}
|
|
1330
|
+
};
|
|
1331
|
+
var stopTree = async (child) => {
|
|
1332
|
+
if (!child.pid) return;
|
|
1333
|
+
if (process.platform === "win32") {
|
|
1334
|
+
spawnSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true });
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
const group = child.pid;
|
|
1338
|
+
const signal = (name) => {
|
|
1339
|
+
try {
|
|
1340
|
+
process.kill(-group, name);
|
|
1341
|
+
} catch {
|
|
663
1342
|
}
|
|
664
|
-
|
|
665
|
-
|
|
1343
|
+
};
|
|
1344
|
+
signal("SIGINT");
|
|
1345
|
+
for (const deadline = Date.now() + KILL_GRACE_MS; groupAlive(group) && Date.now() < deadline; ) await sleep(200);
|
|
1346
|
+
if (groupAlive(group)) {
|
|
1347
|
+
log(`process group ${group} outlived SIGINT by ${KILL_GRACE_MS / 1e3}s; killing it`);
|
|
1348
|
+
signal("SIGKILL");
|
|
1349
|
+
}
|
|
1350
|
+
};
|
|
1351
|
+
var followLines = (stream, file, onLine) => {
|
|
1352
|
+
let pending = "";
|
|
1353
|
+
stream.on("data", (chunk) => {
|
|
1354
|
+
file.write(chunk);
|
|
1355
|
+
pending += chunk.toString("utf8");
|
|
1356
|
+
const lines = pending.split("\n");
|
|
1357
|
+
pending = lines.pop() ?? "";
|
|
1358
|
+
lines.forEach((line) => onLine(line.replace(/\r$/, "")));
|
|
1359
|
+
});
|
|
1360
|
+
stream.on("end", () => {
|
|
1361
|
+
if (pending) onLine(pending);
|
|
1362
|
+
file.end();
|
|
1363
|
+
});
|
|
1364
|
+
};
|
|
1365
|
+
var runJob = async (job) => {
|
|
1366
|
+
const dir = jobDir(job.id);
|
|
1367
|
+
try {
|
|
1368
|
+
const runtime = getCodeVideoCliRuntime();
|
|
1369
|
+
const configPath = path5.join(dir, "config.json");
|
|
1370
|
+
const args = cliArgs({
|
|
1371
|
+
projectJson: fs5.readFileSync(path5.join(dir, "project.json"), "utf8"),
|
|
1372
|
+
outputPath: job.outputPath,
|
|
1373
|
+
configPath: fs5.existsSync(configPath) ? configPath : void 0,
|
|
1374
|
+
options: job.options,
|
|
1375
|
+
open: job.open
|
|
1376
|
+
});
|
|
1377
|
+
fs5.mkdirSync(path5.dirname(job.outputPath), { recursive: true });
|
|
1378
|
+
log(`job ${job.id} (${job.label}) starting`);
|
|
1379
|
+
const [command, commandArgs] = /\.[cm]?js$/i.test(runtime.binaryPath) ? [process.execPath, [runtime.binaryPath, ...args]] : [runtime.binaryPath, args];
|
|
1380
|
+
const child = spawn2(command, commandArgs, {
|
|
1381
|
+
cwd: dir,
|
|
1382
|
+
env: cliEnv(runtime.env, job.options),
|
|
1383
|
+
// Its own process group on Unix, so a cancel can stop Chrome too.
|
|
1384
|
+
detached: process.platform !== "win32",
|
|
1385
|
+
windowsHide: true,
|
|
1386
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1387
|
+
});
|
|
1388
|
+
if (child.pid) setJobPid(job.id, child.pid);
|
|
1389
|
+
let lastError = null;
|
|
1390
|
+
let lastProgress = null;
|
|
1391
|
+
const onLine = (line) => {
|
|
1392
|
+
const parsed = parseCliLine(line);
|
|
1393
|
+
if (parsed.kind === "progress" && parsed.text !== lastProgress) {
|
|
1394
|
+
lastProgress = parsed.text;
|
|
1395
|
+
quietly("progress update", () => setJobProgress(job.id, parsed.text));
|
|
1396
|
+
} else if (parsed.kind === "error") {
|
|
1397
|
+
lastError = parsed.text;
|
|
1398
|
+
}
|
|
1399
|
+
};
|
|
1400
|
+
followLines(child.stdout, fs5.createWriteStream(path5.join(dir, "stdout.log")), onLine);
|
|
1401
|
+
followLines(child.stderr, fs5.createWriteStream(path5.join(dir, "stderr.log")), onLine);
|
|
1402
|
+
let stopping;
|
|
1403
|
+
const cancelPoll = setInterval(() => quietly("cancel check", () => {
|
|
1404
|
+
if (!stopping && isCancelRequested(job.id)) {
|
|
1405
|
+
log(`job ${job.id} cancelling`);
|
|
1406
|
+
stopping = stopTree(child);
|
|
1407
|
+
}
|
|
1408
|
+
}), CANCEL_POLL_MS);
|
|
1409
|
+
const exit = await new Promise((resolve) => {
|
|
1410
|
+
child.once("error", (spawnError) => resolve({ code: null, signal: null, spawnError }));
|
|
1411
|
+
child.once("close", (code, signal) => resolve({ code, signal }));
|
|
1412
|
+
});
|
|
1413
|
+
clearInterval(cancelPoll);
|
|
1414
|
+
if (stopping) {
|
|
1415
|
+
await stopping;
|
|
1416
|
+
fs5.rmSync(job.outputPath, { force: true });
|
|
1417
|
+
finishJob(job.id, "cancelled", null);
|
|
1418
|
+
} else if (exit.spawnError) {
|
|
1419
|
+
finishJob(job.id, "failed", `Couldn't start codevideo-cli: ${exit.spawnError.message}`);
|
|
1420
|
+
} else if (exit.code === 0 && fs5.existsSync(job.outputPath) && fs5.statSync(job.outputPath).size > 0) {
|
|
1421
|
+
finishJob(job.id, "succeeded", null);
|
|
1422
|
+
} else {
|
|
1423
|
+
const how = exit.code === 0 ? "exited without writing the video" : exit.code !== null ? `exited with code ${exit.code}` : `was stopped by ${exit.signal}`;
|
|
1424
|
+
finishJob(job.id, "failed", lastError ?? `codevideo-cli ${how}; see ${path5.join(dir, "stderr.log")}`);
|
|
666
1425
|
}
|
|
667
|
-
|
|
1426
|
+
log(`job ${job.id} finished`);
|
|
1427
|
+
} catch (error) {
|
|
1428
|
+
finishJob(job.id, "failed", error.message);
|
|
1429
|
+
log(`job ${job.id} failed in the worker: ${error.stack ?? error}`);
|
|
1430
|
+
}
|
|
668
1431
|
};
|
|
669
1432
|
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
*/
|
|
673
|
-
|
|
674
|
-
|
|
1433
|
+
// src/render/queue.ts
|
|
1434
|
+
var outputDirectory = () => process.env.CODEVIDEO_OUTPUT_DIR || path6.join(os3.homedir(), "Desktop");
|
|
1435
|
+
var timestamp = () => (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
1436
|
+
var newVideoPath = (name) => {
|
|
1437
|
+
const safe = (name ?? "").replace(/[^a-z0-9-_]+/gi, "_").slice(0, 40);
|
|
1438
|
+
return path6.join(outputDirectory(), safe ? `codevideo-${safe}-${timestamp()}.mp4` : `codevideo-${timestamp()}.mp4`);
|
|
1439
|
+
};
|
|
1440
|
+
var estimatedSeconds = (actions, options) => Math.round(estimateVideoDurationInSeconds(actions, estimatePauses(options)));
|
|
1441
|
+
var queueRenders = (specs) => {
|
|
1442
|
+
const jobs = enqueueJobs(specs);
|
|
1443
|
+
ensureWorker();
|
|
1444
|
+
return jobs;
|
|
1445
|
+
};
|
|
1446
|
+
var lastLines = (file, count) => {
|
|
1447
|
+
try {
|
|
1448
|
+
return fs6.readFileSync(file, "utf8").split("\n").map((line) => line.trimEnd()).filter(Boolean).slice(-count);
|
|
1449
|
+
} catch {
|
|
1450
|
+
return [];
|
|
1451
|
+
}
|
|
1452
|
+
};
|
|
1453
|
+
var describeJob = (job, withLog = false) => {
|
|
1454
|
+
const position = job.status === "queued" ? queuePosition(job.id) : null;
|
|
1455
|
+
return {
|
|
1456
|
+
jobId: job.id,
|
|
1457
|
+
label: job.label,
|
|
1458
|
+
status: job.status,
|
|
1459
|
+
...position !== null ? { jobsAhead: position } : {},
|
|
1460
|
+
outputPath: job.outputPath,
|
|
1461
|
+
...job.status === "succeeded" ? { saved: true } : {},
|
|
1462
|
+
...job.progress ? { progress: job.progress } : {},
|
|
1463
|
+
...job.error ? { error: job.error } : {},
|
|
1464
|
+
createdAt: job.createdAt,
|
|
1465
|
+
...job.startedAt ? { startedAt: job.startedAt } : {},
|
|
1466
|
+
...job.finishedAt ? { finishedAt: job.finishedAt } : {},
|
|
1467
|
+
...withLog ? { logTail: lastLines(path6.join(jobDir(job.id), "stderr.log"), 8), logs: jobDir(job.id) } : {}
|
|
1468
|
+
};
|
|
1469
|
+
};
|
|
1470
|
+
var describeJobById = (jobId, withLog = false) => {
|
|
1471
|
+
const job = getJob(jobId);
|
|
1472
|
+
return job ? describeJob(job, withLog) : null;
|
|
675
1473
|
};
|
|
676
1474
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
1475
|
+
// src/tools/make_video_from_actions.ts
|
|
1476
|
+
var queueVideoFromActions = (actions, options = {}, open = true) => {
|
|
1477
|
+
const actionsToUse = actions || getCurrentActionsOrThrow();
|
|
1478
|
+
const [job] = queueRenders([{
|
|
1479
|
+
kind: "actions",
|
|
1480
|
+
label: `${actionsToUse.length} actions`,
|
|
1481
|
+
project: actionsToUse,
|
|
1482
|
+
outputPath: newVideoPath(),
|
|
1483
|
+
options,
|
|
1484
|
+
open,
|
|
1485
|
+
defaultLanguage: options.defaultLanguage ?? "typescript"
|
|
1486
|
+
}]);
|
|
1487
|
+
return { job, estimatedSeconds: estimatedSeconds(actionsToUse, options) };
|
|
1488
|
+
};
|
|
1489
|
+
|
|
1490
|
+
// src/tools/make_video_from_lesson.ts
|
|
1491
|
+
var queueVideoFromLesson = (lesson, options = {}, open = true) => {
|
|
1492
|
+
const lessonToUse = lesson || getCurrentLessonOrThrow();
|
|
1493
|
+
const [job] = queueRenders([{
|
|
1494
|
+
kind: "lesson",
|
|
1495
|
+
label: lessonToUse.name || "lesson",
|
|
1496
|
+
project: lessonToUse,
|
|
1497
|
+
outputPath: newVideoPath(lessonToUse.name),
|
|
1498
|
+
options,
|
|
1499
|
+
open,
|
|
1500
|
+
defaultLanguage: options.defaultLanguage ?? "typescript"
|
|
1501
|
+
}]);
|
|
1502
|
+
return { job, estimatedSeconds: estimatedSeconds(lessonToUse.actions, options) };
|
|
1503
|
+
};
|
|
1504
|
+
|
|
1505
|
+
// src/tools/validate_actions.ts
|
|
1506
|
+
import { isValidAction } from "@fullstackcraftllc/codevideo-types";
|
|
1507
|
+
|
|
1508
|
+
// src/validation/rejectedActions.ts
|
|
1509
|
+
import { AllActionDescriptions as AllActionDescriptions2 } from "@fullstackcraftllc/codevideo-types";
|
|
1510
|
+
var REJECTED_ACTIONS = /* @__PURE__ */ new Map([
|
|
1511
|
+
["file-explorer-create-file", "make the file with the mouse (right-click, New File) or touch in the terminal"],
|
|
1512
|
+
["file-explorer-create-folder", "make the folder with the mouse (right-click, New Folder) or mkdir in the terminal"],
|
|
1513
|
+
["file-explorer-set-file-contents", "type the contents with editor-type"],
|
|
1514
|
+
["editor-highlight-code", "not supported yet; to point at code, bring its file forward with mouse-move-editor-tab and talk about it"],
|
|
1515
|
+
["composite-create-file-with-mouse", "not supported yet; make the file with the mouse steps (right-click, New File)"],
|
|
1516
|
+
["composite-create-file-with-terminal", "not supported yet; type touch <path> with terminal-type, then terminal-enter"],
|
|
1517
|
+
["composite-create-folder-with-mouse", "not supported yet; make the folder with the mouse steps (right-click, New Folder)"],
|
|
1518
|
+
["composite-create-folder-with-terminal", "not supported yet; type mkdir <path> with terminal-type, then terminal-enter"],
|
|
1519
|
+
["composite-close-all-editors-with-mouse", "not supported yet; close editors one at a time with mouse-move-editor-tab-close and mouse-left-click"],
|
|
1520
|
+
["composite-close-all-editors-with-keyboard", "not supported yet; close editors one at a time with mouse-move-editor-tab-close and mouse-left-click"]
|
|
1521
|
+
]);
|
|
1522
|
+
var rejectedActionsIn = (actions) => actions.flatMap((action, index) => REJECTED_ACTIONS.has(action.name) ? [{ index, name: action.name }] : []);
|
|
1523
|
+
var describeRejected = (rejected) => rejected.map((action) => `action ${action.index} is ${action.name}: ${REJECTED_ACTIONS.get(action.name)}`).join("; ");
|
|
1524
|
+
var valueHint = (name) => AllActionDescriptions2.find((action) => action.name === name)?.valueDescription;
|
|
1525
|
+
|
|
1526
|
+
// src/tools/validate_actions.ts
|
|
1527
|
+
var validateActions = (actions) => {
|
|
1528
|
+
const actionsToUse = actions || getCurrentActionsOrThrow();
|
|
1529
|
+
const errorMessages = [];
|
|
1530
|
+
for (var i = 0; i < actionsToUse.length; i++) {
|
|
1531
|
+
const action = actionsToUse[i];
|
|
1532
|
+
const hint = valueHint(action.name);
|
|
1533
|
+
if (!isValidAction(action)) {
|
|
1534
|
+
errorMessages.push(hint ? `Invalid action at action index ${i}: ${JSON.stringify(action)}. Its value should be: ${hint}` : `Invalid action at action index ${i}: ${JSON.stringify(action)}. Unknown action name; codevideo_get_action_names lists them all.`);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
const rejected = rejectedActionsIn(actionsToUse);
|
|
1538
|
+
if (rejected.length > 0) {
|
|
1539
|
+
errorMessages.push(`Actions lessons can't use: ${describeRejected(rejected)}.`);
|
|
1540
|
+
}
|
|
1541
|
+
if (errorMessages.length > 0) {
|
|
1542
|
+
return JSON.stringify({ isError: true, errorMessages });
|
|
1543
|
+
}
|
|
1544
|
+
return JSON.stringify({ isError: false, errorMessages: [] });
|
|
1545
|
+
};
|
|
1546
|
+
|
|
1547
|
+
// src/tools/courseTools.ts
|
|
1548
|
+
import fs7 from "node:fs";
|
|
1549
|
+
import path7 from "node:path";
|
|
1550
|
+
import { isValidAction as isValidAction2 } from "@fullstackcraftllc/codevideo-types";
|
|
1551
|
+
|
|
1552
|
+
// src/store/courseStore.ts
|
|
1553
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
1554
|
+
import { VirtualIDE as VirtualIDE2 } from "@fullstackcraftllc/codevideo-virtual-ide";
|
|
1555
|
+
|
|
1556
|
+
// src/utils/emptyCourseSnapshot.ts
|
|
1557
|
+
var emptyCourseSnapshot = () => ({
|
|
1558
|
+
isUnsavedChangesDialogOpen: false,
|
|
1559
|
+
unsavedFileName: "",
|
|
1560
|
+
fileExplorerSnapshot: {
|
|
1561
|
+
isFileExplorerContextMenuOpen: false,
|
|
1562
|
+
isFileContextMenuOpen: false,
|
|
1563
|
+
isFolderContextMenuOpen: false,
|
|
1564
|
+
isNewFileInputVisible: false,
|
|
1565
|
+
isNewFolderInputVisible: false,
|
|
1566
|
+
isRenameFileInputVisible: false,
|
|
1567
|
+
isRenameFolderInputVisible: false,
|
|
1568
|
+
newFileInputValue: "",
|
|
1569
|
+
newFolderInputValue: "",
|
|
1570
|
+
renameFileInputValue: "",
|
|
1571
|
+
renameFolderInputValue: "",
|
|
1572
|
+
originalFileBeingRenamed: "",
|
|
1573
|
+
originalFolderBeingRenamed: "",
|
|
1574
|
+
newFileParentPath: "",
|
|
1575
|
+
newFolderParentPath: "",
|
|
1576
|
+
fileStructure: {}
|
|
1577
|
+
},
|
|
1578
|
+
editorSnapshot: {
|
|
1579
|
+
isEditorContextMenuOpen: false,
|
|
1580
|
+
editors: []
|
|
1581
|
+
},
|
|
1582
|
+
terminalSnapshot: {
|
|
1583
|
+
terminals: []
|
|
1584
|
+
},
|
|
1585
|
+
mouseSnapshot: {
|
|
1586
|
+
location: "editor",
|
|
1587
|
+
currentHoveredFileName: "",
|
|
1588
|
+
currentHoveredFolderName: "",
|
|
1589
|
+
currentHoveredEditorTabFileName: "",
|
|
1590
|
+
x: 0,
|
|
1591
|
+
y: 0,
|
|
1592
|
+
timestamp: 0,
|
|
1593
|
+
type: "move",
|
|
1594
|
+
buttonStates: {
|
|
1595
|
+
left: false,
|
|
1596
|
+
right: false,
|
|
1597
|
+
middle: false
|
|
706
1598
|
},
|
|
707
|
-
{
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
1599
|
+
scrollPosition: {
|
|
1600
|
+
x: 0,
|
|
1601
|
+
y: 0
|
|
1602
|
+
}
|
|
1603
|
+
},
|
|
1604
|
+
authorSnapshot: {
|
|
1605
|
+
authors: [
|
|
1606
|
+
{ currentSpeechCaption: "" }
|
|
1607
|
+
]
|
|
1608
|
+
}
|
|
1609
|
+
});
|
|
1610
|
+
|
|
1611
|
+
// src/store/courseStore.ts
|
|
1612
|
+
var COURSE_SCHEMA = `
|
|
1613
|
+
CREATE TABLE IF NOT EXISTS courses (
|
|
1614
|
+
id TEXT PRIMARY KEY,
|
|
1615
|
+
name TEXT NOT NULL,
|
|
1616
|
+
description TEXT NOT NULL DEFAULT '',
|
|
1617
|
+
primary_language TEXT NOT NULL DEFAULT '',
|
|
1618
|
+
lesson_order TEXT NOT NULL DEFAULT '[]',
|
|
1619
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
1620
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
1621
|
+
);
|
|
1622
|
+
CREATE TABLE IF NOT EXISTS lessons (
|
|
1623
|
+
id TEXT PRIMARY KEY,
|
|
1624
|
+
course_id TEXT NOT NULL,
|
|
1625
|
+
name TEXT NOT NULL,
|
|
1626
|
+
description TEXT NOT NULL DEFAULT '',
|
|
1627
|
+
must_compile INTEGER NOT NULL DEFAULT 1,
|
|
1628
|
+
continues_from_previous INTEGER NOT NULL DEFAULT 1,
|
|
1629
|
+
actions TEXT NOT NULL DEFAULT '[]',
|
|
1630
|
+
initial_snapshot TEXT NOT NULL,
|
|
1631
|
+
final_snapshot TEXT NOT NULL,
|
|
1632
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
1633
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
1634
|
+
);
|
|
1635
|
+
CREATE INDEX IF NOT EXISTS idx_lessons_course ON lessons(course_id);
|
|
1636
|
+
`;
|
|
1637
|
+
var withDb = (fn) => withDatabase((db) => {
|
|
1638
|
+
db.exec(COURSE_SCHEMA);
|
|
1639
|
+
return fn(db);
|
|
1640
|
+
});
|
|
1641
|
+
var asRow = (row) => row;
|
|
1642
|
+
var replay = (initial, actions) => {
|
|
1643
|
+
const virtualIDE = new VirtualIDE2();
|
|
1644
|
+
virtualIDE.applyCourseSnapshot(initial);
|
|
1645
|
+
if (actions.length > 0) {
|
|
1646
|
+
virtualIDE.applyActions(actions);
|
|
1647
|
+
}
|
|
1648
|
+
return virtualIDE.getCourseSnapshot();
|
|
1649
|
+
};
|
|
1650
|
+
var replayBreaks = (initial, actions) => {
|
|
1651
|
+
try {
|
|
1652
|
+
replay(initial, actions);
|
|
1653
|
+
return false;
|
|
1654
|
+
} catch {
|
|
1655
|
+
return true;
|
|
1656
|
+
}
|
|
1657
|
+
};
|
|
1658
|
+
var replayError = (initial, actions, error) => {
|
|
1659
|
+
if (replayBreaks(initial, [])) return new Error(`The lesson's starting state can't be loaded: ${error.message}`);
|
|
1660
|
+
let works = 0;
|
|
1661
|
+
let breaks = actions.length;
|
|
1662
|
+
while (breaks - works > 1) {
|
|
1663
|
+
const mid = works + breaks >> 1;
|
|
1664
|
+
if (replayBreaks(initial, actions.slice(0, mid))) breaks = mid;
|
|
1665
|
+
else works = mid;
|
|
1666
|
+
}
|
|
1667
|
+
const index = breaks - 1;
|
|
1668
|
+
const action = actions[index];
|
|
1669
|
+
const pathHint = `names something that exists at that point, by path (e.g. "src/index.ts")`;
|
|
1670
|
+
const moveIndex = /click/.test(action.name) ? actions.slice(0, index).map((a) => a.name.startsWith("mouse-move")).lastIndexOf(true) : -1;
|
|
1671
|
+
if (moveIndex >= 0) {
|
|
1672
|
+
const move = actions[moveIndex];
|
|
1673
|
+
return new Error(
|
|
1674
|
+
`Action ${index} (${action.name}) clicks what action ${moveIndex} (${move.name} ${JSON.stringify(move.value)}) points at, and that breaks the replay: ${error.message}. Check that action ${moveIndex}'s value ${pathHint}.`
|
|
1675
|
+
);
|
|
1676
|
+
}
|
|
1677
|
+
return new Error(`Action ${index} (${action.name} ${JSON.stringify(action.value)}) breaks the replay: ${error.message}. Check that its value ${pathHint}.`);
|
|
1678
|
+
};
|
|
1679
|
+
var snapshotFromActions = (initial, actions) => {
|
|
1680
|
+
try {
|
|
1681
|
+
return replay(initial, actions);
|
|
1682
|
+
} catch (error) {
|
|
1683
|
+
throw replayError(initial, actions, error);
|
|
1684
|
+
}
|
|
1685
|
+
};
|
|
1686
|
+
var fileEntriesFromSnapshot = (snapshot) => {
|
|
1687
|
+
const virtualIDE = new VirtualIDE2();
|
|
1688
|
+
virtualIDE.applyCourseSnapshot(snapshot);
|
|
1689
|
+
return virtualIDE.getFullFilePathsAndContents();
|
|
1690
|
+
};
|
|
1691
|
+
var rowToLesson = (row) => ({
|
|
1692
|
+
id: row.id,
|
|
1693
|
+
courseId: row.course_id,
|
|
1694
|
+
name: row.name,
|
|
1695
|
+
description: row.description,
|
|
1696
|
+
mustCompile: row.must_compile === 1,
|
|
1697
|
+
continuesFromPrevious: row.continues_from_previous === 1,
|
|
1698
|
+
actions: JSON.parse(row.actions),
|
|
1699
|
+
initialSnapshot: JSON.parse(row.initial_snapshot),
|
|
1700
|
+
finalSnapshot: JSON.parse(row.final_snapshot)
|
|
1701
|
+
});
|
|
1702
|
+
var getLessonOrder = (db, courseId) => {
|
|
1703
|
+
const row = asRow(db.prepare(`SELECT lesson_order FROM courses WHERE id = ?`).get(courseId));
|
|
1704
|
+
if (!row) throw new Error(`Course not found: ${courseId}`);
|
|
1705
|
+
return JSON.parse(row.lesson_order);
|
|
1706
|
+
};
|
|
1707
|
+
var getLessonsOrdered = (db, courseId) => {
|
|
1708
|
+
const order = getLessonOrder(db, courseId);
|
|
1709
|
+
const get = db.prepare(`SELECT * FROM lessons WHERE id = ?`);
|
|
1710
|
+
return order.map((id) => asRow(get.get(id))).filter((r) => !!r).map(rowToLesson);
|
|
1711
|
+
};
|
|
1712
|
+
var recomputeChain = (db, courseId) => {
|
|
1713
|
+
const order = getLessonOrder(db, courseId);
|
|
1714
|
+
const getRow = db.prepare(`SELECT * FROM lessons WHERE id = ?`);
|
|
1715
|
+
const update = db.prepare(`UPDATE lessons SET initial_snapshot = ?, final_snapshot = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`);
|
|
1716
|
+
let previousFinal = null;
|
|
1717
|
+
for (const lessonId of order) {
|
|
1718
|
+
const row = asRow(getRow.get(lessonId));
|
|
1719
|
+
if (!row) continue;
|
|
1720
|
+
const lesson = rowToLesson(row);
|
|
1721
|
+
const initial = lesson.continuesFromPrevious && previousFinal ? previousFinal : lesson.initialSnapshot;
|
|
1722
|
+
let final;
|
|
1723
|
+
try {
|
|
1724
|
+
final = snapshotFromActions(initial, lesson.actions);
|
|
1725
|
+
} catch (error) {
|
|
1726
|
+
throw new Error(`Lesson "${lesson.name}": ${error.message}`);
|
|
1727
|
+
}
|
|
1728
|
+
update.run(JSON.stringify(initial), JSON.stringify(final), lessonId);
|
|
1729
|
+
previousFinal = final;
|
|
1730
|
+
}
|
|
1731
|
+
};
|
|
1732
|
+
var createCourse = (input) => withDb((db) => {
|
|
1733
|
+
const id = input.id && input.id.trim().length > 0 ? input.id.trim() : randomUUID2();
|
|
1734
|
+
db.prepare(`
|
|
1735
|
+
INSERT INTO courses (id, name, description, primary_language, lesson_order)
|
|
1736
|
+
VALUES (?, ?, ?, ?, '[]')
|
|
1737
|
+
`).run(id, input.name, input.description ?? "", input.primaryLanguage ?? "");
|
|
1738
|
+
return id;
|
|
1739
|
+
});
|
|
1740
|
+
var addLesson = (courseId, input) => withDb((db) => {
|
|
1741
|
+
const order = getLessonOrder(db, courseId);
|
|
1742
|
+
const continuesFromPrevious = input.continuesFromPrevious ?? order.length > 0;
|
|
1743
|
+
let initial;
|
|
1744
|
+
if (continuesFromPrevious && order.length > 0) {
|
|
1745
|
+
const prev = asRow(db.prepare(`SELECT final_snapshot FROM lessons WHERE id = ?`).get(order[order.length - 1]));
|
|
1746
|
+
initial = prev ? JSON.parse(prev.final_snapshot) : emptyCourseSnapshot();
|
|
1747
|
+
} else {
|
|
1748
|
+
initial = input.initialSnapshot ?? emptyCourseSnapshot();
|
|
1749
|
+
}
|
|
1750
|
+
const lessonId = randomUUID2();
|
|
1751
|
+
transaction(db, () => {
|
|
1752
|
+
db.prepare(`
|
|
1753
|
+
INSERT INTO lessons (id, course_id, name, description, must_compile, continues_from_previous, actions, initial_snapshot, final_snapshot)
|
|
1754
|
+
VALUES (?, ?, ?, ?, ?, ?, '[]', ?, ?)
|
|
1755
|
+
`).run(
|
|
1756
|
+
lessonId,
|
|
1757
|
+
courseId,
|
|
1758
|
+
input.name,
|
|
1759
|
+
input.description ?? "",
|
|
1760
|
+
input.mustCompile ?? true ? 1 : 0,
|
|
1761
|
+
continuesFromPrevious ? 1 : 0,
|
|
1762
|
+
JSON.stringify(initial),
|
|
1763
|
+
JSON.stringify(initial)
|
|
1764
|
+
// no actions yet => final == initial
|
|
1765
|
+
);
|
|
1766
|
+
order.push(lessonId);
|
|
1767
|
+
db.prepare(`UPDATE courses SET lesson_order = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`).run(JSON.stringify(order), courseId);
|
|
1768
|
+
});
|
|
1769
|
+
return lessonId;
|
|
1770
|
+
});
|
|
1771
|
+
var setLessonActions = (courseId, lessonId, actions) => withDb(
|
|
1772
|
+
(db) => transaction(db, () => {
|
|
1773
|
+
const exists = db.prepare(`SELECT 1 FROM lessons WHERE id = ? AND course_id = ?`).get(lessonId, courseId);
|
|
1774
|
+
if (!exists) throw new Error(`Lesson not found: ${lessonId} in course ${courseId}`);
|
|
1775
|
+
db.prepare(`UPDATE lessons SET actions = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`).run(JSON.stringify(actions), lessonId);
|
|
1776
|
+
recomputeChain(db, courseId);
|
|
1777
|
+
return { actionCount: actions.length };
|
|
1778
|
+
})
|
|
1779
|
+
);
|
|
1780
|
+
var editLessonActions = (courseId, lessonId, edits) => withDb(
|
|
1781
|
+
(db) => transaction(db, () => {
|
|
1782
|
+
const row = asRow(db.prepare(`SELECT actions FROM lessons WHERE id = ? AND course_id = ?`).get(lessonId, courseId));
|
|
1783
|
+
if (!row) throw new Error(`Lesson not found: ${lessonId} in course ${courseId}`);
|
|
1784
|
+
const actions = JSON.parse(row.actions);
|
|
1785
|
+
edits.forEach((edit, i) => {
|
|
1786
|
+
if (!Number.isInteger(edit.start) || edit.start < 0 || edit.start > actions.length) {
|
|
1787
|
+
throw new Error(`Edit ${i}: start ${edit.start} is outside 0..${actions.length}`);
|
|
1788
|
+
}
|
|
1789
|
+
if (!Number.isInteger(edit.deleteCount) || edit.deleteCount < 0 || edit.start + edit.deleteCount > actions.length) {
|
|
1790
|
+
throw new Error(`Edit ${i}: deleting ${edit.deleteCount} from ${edit.start} runs past the last action (${actions.length})`);
|
|
1791
|
+
}
|
|
1792
|
+
actions.splice(edit.start, edit.deleteCount, ...edit.insert);
|
|
1793
|
+
});
|
|
1794
|
+
db.prepare(`UPDATE lessons SET actions = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`).run(JSON.stringify(actions), lessonId);
|
|
1795
|
+
recomputeChain(db, courseId);
|
|
1796
|
+
return { actionCount: actions.length };
|
|
1797
|
+
})
|
|
1798
|
+
);
|
|
1799
|
+
var getLessonActions = (courseId, lessonId, start = 0, limit = 50) => {
|
|
1800
|
+
const lesson = getLesson(courseId, lessonId);
|
|
1801
|
+
const from = Math.max(0, Math.min(start, lesson.actions.length));
|
|
1802
|
+
return {
|
|
1803
|
+
total: lesson.actions.length,
|
|
1804
|
+
start: from,
|
|
1805
|
+
actions: lesson.actions.slice(from, from + Math.max(0, limit)).map((a, i) => ({ index: from + i, name: a.name, value: a.value }))
|
|
1806
|
+
};
|
|
1807
|
+
};
|
|
1808
|
+
var getLesson = (courseId, lessonId) => withDb((db) => {
|
|
1809
|
+
const row = asRow(db.prepare(`SELECT * FROM lessons WHERE id = ? AND course_id = ?`).get(lessonId, courseId));
|
|
1810
|
+
if (!row) throw new Error(`Lesson not found: ${lessonId} in course ${courseId}`);
|
|
1811
|
+
return rowToLesson(row);
|
|
1812
|
+
});
|
|
1813
|
+
var listLessons = (courseId) => withDb((db) => getLessonsOrdered(db, courseId));
|
|
1814
|
+
var getCourse = (courseId) => withDb((db) => {
|
|
1815
|
+
const c = asRow(
|
|
1816
|
+
db.prepare(`SELECT id, name, description, primary_language FROM courses WHERE id = ?`).get(courseId)
|
|
1817
|
+
);
|
|
1818
|
+
if (!c) throw new Error(`Course not found: ${courseId}`);
|
|
1819
|
+
return {
|
|
1820
|
+
id: c.id,
|
|
1821
|
+
name: c.name,
|
|
1822
|
+
description: c.description,
|
|
1823
|
+
primaryLanguage: c.primary_language,
|
|
1824
|
+
lessons: getLessonsOrdered(db, courseId)
|
|
1825
|
+
};
|
|
1826
|
+
});
|
|
1827
|
+
var exportCourse = (courseId) => {
|
|
1828
|
+
const course = getCourse(courseId);
|
|
1829
|
+
const lessons = course.lessons.map((l) => ({
|
|
1830
|
+
id: l.id,
|
|
1831
|
+
name: l.name,
|
|
1832
|
+
description: l.description,
|
|
1833
|
+
actions: l.actions,
|
|
1834
|
+
initialSnapshot: l.initialSnapshot,
|
|
1835
|
+
finalSnapshot: l.finalSnapshot
|
|
1836
|
+
}));
|
|
1837
|
+
return {
|
|
1838
|
+
id: course.id,
|
|
1839
|
+
name: course.name,
|
|
1840
|
+
description: course.description,
|
|
1841
|
+
primaryLanguage: course.primaryLanguage,
|
|
1842
|
+
lessons
|
|
1843
|
+
};
|
|
1844
|
+
};
|
|
1845
|
+
var getLessonFileTree = (courseId, lessonId) => {
|
|
1846
|
+
const lesson = getLesson(courseId, lessonId);
|
|
1847
|
+
return fileEntriesFromSnapshot(lesson.finalSnapshot).map((f) => f.path).sort();
|
|
1848
|
+
};
|
|
1849
|
+
var samePath = (a, b) => a.replace(/^\.?\/+/, "") === b.replace(/^\.?\/+/, "");
|
|
1850
|
+
var getLessonFile = (courseId, lessonId, filePath) => {
|
|
1851
|
+
const lesson = getLesson(courseId, lessonId);
|
|
1852
|
+
const entry = fileEntriesFromSnapshot(lesson.finalSnapshot).find((f) => samePath(f.path, filePath));
|
|
1853
|
+
return entry ? entry.content : null;
|
|
1854
|
+
};
|
|
1855
|
+
var getLessonFiles = (courseId, lessonId) => {
|
|
1856
|
+
const lesson = getLesson(courseId, lessonId);
|
|
1857
|
+
return fileEntriesFromSnapshot(lesson.finalSnapshot);
|
|
1858
|
+
};
|
|
1859
|
+
|
|
1860
|
+
// src/tools/compileCheck.ts
|
|
1861
|
+
import { createRequire as createRequire2 } from "node:module";
|
|
1862
|
+
var cachedCompileProject = null;
|
|
1863
|
+
var getCompileProject = () => {
|
|
1864
|
+
if (!cachedCompileProject) {
|
|
1865
|
+
const require2 = createRequire2(import.meta.url);
|
|
1866
|
+
cachedCompileProject = require2("@fullstackcraftllc/codevideo-dynamic-ast").compileProject;
|
|
1867
|
+
}
|
|
1868
|
+
return cachedCompileProject;
|
|
1869
|
+
};
|
|
1870
|
+
var compileFiles = async (files) => {
|
|
1871
|
+
if (files.length === 0) {
|
|
1872
|
+
return { status: "ok", projects: [], message: "No files to compile." };
|
|
1873
|
+
}
|
|
1874
|
+
try {
|
|
1875
|
+
const compileProject = getCompileProject();
|
|
1876
|
+
const result = await compileProject(files);
|
|
1877
|
+
const projects = result.projects.map((p) => ({
|
|
1878
|
+
language: p.language,
|
|
1879
|
+
errors: p.errors
|
|
1880
|
+
}));
|
|
1881
|
+
const hasErrors = projects.some((p) => p.errors.length > 0);
|
|
1882
|
+
return { status: hasErrors ? "errors" : "ok", projects };
|
|
1883
|
+
} catch (error) {
|
|
1884
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1885
|
+
return {
|
|
1886
|
+
status: "errors",
|
|
1887
|
+
projects: [{ language: "unknown", errors: [{ file: "unknown", line: 0, message: `Compile check failed to run: ${message}` }] }]
|
|
1888
|
+
};
|
|
1889
|
+
}
|
|
1890
|
+
};
|
|
1891
|
+
|
|
1892
|
+
// src/tools/courseTools.ts
|
|
1893
|
+
var ok = (data) => JSON.stringify(data, null, 2);
|
|
1894
|
+
var createCourseTool = (input) => {
|
|
1895
|
+
const courseId = createCourse(input);
|
|
1896
|
+
return ok({ courseId, message: `Created course "${input.name}". Add lessons with codevideo_add_lesson(courseId).` });
|
|
1897
|
+
};
|
|
1898
|
+
var filesChangedBy = (lesson) => {
|
|
1899
|
+
const before = new Map(fileEntriesFromSnapshot(lesson.initialSnapshot).map((f) => [f.path, f.content]));
|
|
1900
|
+
const open = new Set((lesson.finalSnapshot.editorSnapshot?.editors ?? []).map((e) => e.filename));
|
|
1901
|
+
return fileEntriesFromSnapshot(lesson.finalSnapshot).filter((f) => before.get(f.path) !== f.content).slice(0, 10).map((f) => {
|
|
1902
|
+
const path9 = f.path.replace(/^\/+/, "");
|
|
1903
|
+
return { path: path9, change: before.has(f.path) ? "changed" : "created", open: open.has(path9) };
|
|
1904
|
+
});
|
|
1905
|
+
};
|
|
1906
|
+
var addLessonTool = (courseId, input) => {
|
|
1907
|
+
const lessonId = addLesson(courseId, input);
|
|
1908
|
+
const lessons = listLessons(courseId);
|
|
1909
|
+
const index = lessons.findIndex((l) => l.id === lessonId);
|
|
1910
|
+
const lesson = lessons[index];
|
|
1911
|
+
const previous = index > 0 ? lessons[index - 1] : void 0;
|
|
1912
|
+
return ok({
|
|
1913
|
+
lessonId,
|
|
1914
|
+
continuesFromPrevious: lesson.continuesFromPrevious,
|
|
1915
|
+
mustCompile: lesson.mustCompile,
|
|
1916
|
+
// What the opening recap is written from, without loading that lesson.
|
|
1917
|
+
...previous ? { previousLesson: { name: previous.name, description: previous.description, files: filesChangedBy(previous) } } : {},
|
|
1918
|
+
message: `Added lesson "${input.name}". ` + (lesson.continuesFromPrevious ? "Its initial state is the previous lesson's final state (chained server-side)." : "Its initial state is empty (from scratch).") + " Author its actions, then call codevideo_set_lesson_actions(courseId, lessonId, actions)."
|
|
1919
|
+
});
|
|
1920
|
+
};
|
|
1921
|
+
var problemsWith = (actions) => {
|
|
1922
|
+
const rejected = rejectedActionsIn(actions);
|
|
1923
|
+
return rejected.length === 0 ? {} : { problems: `Stored, but codevideo_validate_course rejects this lesson until these are replaced: ${describeRejected(rejected)}. codevideo_edit_lesson_actions can swap just those actions.` };
|
|
1924
|
+
};
|
|
1925
|
+
var setLessonActionsTool = (courseId, lessonId, actions) => {
|
|
1926
|
+
const { actionCount } = setLessonActions(courseId, lessonId, actions);
|
|
1927
|
+
return ok({
|
|
1928
|
+
ok: true,
|
|
1929
|
+
actionCount,
|
|
1930
|
+
message: `Stored ${actionCount} actions. Final snapshot recomputed server-side. Use codevideo_compile_lesson to check it builds.`,
|
|
1931
|
+
...problemsWith(actions)
|
|
1932
|
+
});
|
|
1933
|
+
};
|
|
1934
|
+
var editLessonActionsTool = (courseId, lessonId, edits) => {
|
|
1935
|
+
const { actionCount } = editLessonActions(courseId, lessonId, edits);
|
|
1936
|
+
return ok({
|
|
1937
|
+
ok: true,
|
|
1938
|
+
actionCount,
|
|
1939
|
+
message: `Applied ${edits.length} edit(s); the lesson now has ${actionCount} actions. Snapshots recomputed server-side.`,
|
|
1940
|
+
...problemsWith(getLesson(courseId, lessonId).actions)
|
|
1941
|
+
});
|
|
1942
|
+
};
|
|
1943
|
+
var getLessonActionsTool = (courseId, lessonId, start, limit) => ok({ courseId, lessonId, ...getLessonActions(courseId, lessonId, start, limit) });
|
|
1944
|
+
var getLessonFileTreeTool = (courseId, lessonId) => ok({ courseId, lessonId, files: getLessonFileTree(courseId, lessonId) });
|
|
1945
|
+
var getLessonFileTool = (courseId, lessonId, filePath) => {
|
|
1946
|
+
const content = getLessonFile(courseId, lessonId, filePath);
|
|
1947
|
+
if (content === null) {
|
|
1948
|
+
return ok({ courseId, lessonId, path: filePath, found: false, message: "No such file at the end of this lesson." });
|
|
1949
|
+
}
|
|
1950
|
+
return ok({ courseId, lessonId, path: filePath, found: true, content });
|
|
1951
|
+
};
|
|
1952
|
+
var compileLessonTool = async (courseId, lessonId) => {
|
|
1953
|
+
const files = getLessonFiles(courseId, lessonId);
|
|
1954
|
+
const result = await compileFiles(files);
|
|
1955
|
+
return ok({ courseId, lessonId, fileCount: files.length, ...result });
|
|
1956
|
+
};
|
|
1957
|
+
var validateCourse = async (courseId) => {
|
|
1958
|
+
const lessons = listLessons(courseId);
|
|
1959
|
+
const results = [];
|
|
1960
|
+
for (let i = 0; i < lessons.length; i++) {
|
|
1961
|
+
const lesson = lessons[i];
|
|
1962
|
+
const invalidActionIndexes = [];
|
|
1963
|
+
lesson.actions.forEach((a, idx) => {
|
|
1964
|
+
if (!isValidAction2(a)) invalidActionIndexes.push(idx);
|
|
1965
|
+
});
|
|
1966
|
+
const rejectedActions = rejectedActionsIn(lesson.actions);
|
|
1967
|
+
const opensWithSpeech = lesson.actions.length === 0 || lesson.actions[0].name === "author-speak-before";
|
|
1968
|
+
let snapshotContinuous = true;
|
|
1969
|
+
if (i > 0 && lesson.continuesFromPrevious) {
|
|
1970
|
+
snapshotContinuous = JSON.stringify(lesson.initialSnapshot) === JSON.stringify(lessons[i - 1].finalSnapshot);
|
|
1971
|
+
}
|
|
1972
|
+
let compileStatus = "skipped";
|
|
1973
|
+
let compileErrors = [];
|
|
1974
|
+
if (lesson.mustCompile) {
|
|
1975
|
+
const c = await compileFiles(getLessonFiles(courseId, lesson.id));
|
|
1976
|
+
compileStatus = c.status;
|
|
1977
|
+
compileErrors = c.projects.filter((p) => p.errors.length > 0);
|
|
1978
|
+
}
|
|
1979
|
+
results.push({
|
|
1980
|
+
lessonId: lesson.id,
|
|
1981
|
+
name: lesson.name,
|
|
1982
|
+
index: i,
|
|
1983
|
+
actionsValid: invalidActionIndexes.length === 0,
|
|
1984
|
+
hasActions: lesson.actions.length > 0,
|
|
1985
|
+
invalidActionIndexes,
|
|
1986
|
+
rejectedActions,
|
|
1987
|
+
opensWithSpeech,
|
|
1988
|
+
snapshotContinuous,
|
|
1989
|
+
mustCompile: lesson.mustCompile,
|
|
1990
|
+
compileStatus,
|
|
1991
|
+
compileErrors
|
|
1992
|
+
});
|
|
1993
|
+
}
|
|
1994
|
+
const allActionsValid = results.every((r) => r.actionsValid);
|
|
1995
|
+
const allAllowed = results.every((r) => r.rejectedActions.length === 0);
|
|
1996
|
+
const allOpenWithSpeech = results.every((r) => r.opensWithSpeech);
|
|
1997
|
+
const allHaveActions = results.every((r) => r.hasActions);
|
|
1998
|
+
const allContinuous = results.every((r) => r.snapshotContinuous);
|
|
1999
|
+
const compileEnforced = results.filter((r) => r.mustCompile);
|
|
2000
|
+
const allMustCompileClean = compileEnforced.every((r) => r.compileStatus === "ok");
|
|
2001
|
+
const finalLesson = results[results.length - 1];
|
|
2002
|
+
const finalCompilesClean = !!finalLesson && finalLesson.mustCompile && finalLesson.compileStatus === "ok";
|
|
2003
|
+
const done = lessons.length > 0 && allHaveActions && allActionsValid && allAllowed && allOpenWithSpeech && allContinuous && allMustCompileClean && finalCompilesClean;
|
|
2004
|
+
const reasons = [];
|
|
2005
|
+
if (lessons.length === 0) reasons.push("Course has no lessons.");
|
|
2006
|
+
for (const lesson of results.filter((result) => !result.hasActions)) {
|
|
2007
|
+
reasons.push(`Lesson "${lesson.name}" (${lesson.lessonId}) has no actions.`);
|
|
2008
|
+
}
|
|
2009
|
+
results.forEach((result, i) => {
|
|
2010
|
+
if (result.invalidActionIndexes.length === 0) return;
|
|
2011
|
+
const details = result.invalidActionIndexes.map((index) => {
|
|
2012
|
+
const action = lessons[i].actions[index];
|
|
2013
|
+
const hint = valueHint(action.name);
|
|
2014
|
+
return hint ? `action ${index} is ${action.name} ${JSON.stringify(action.value)}, but its value should be: ${hint}` : `action ${index} has an unknown name, ${JSON.stringify(action.name)}`;
|
|
2015
|
+
});
|
|
2016
|
+
reasons.push(`Lesson "${result.name}" (${result.lessonId}) has invalid actions: ${details.join("; ")}.`);
|
|
2017
|
+
});
|
|
2018
|
+
for (const result of results.filter((r) => !r.opensWithSpeech)) {
|
|
2019
|
+
reasons.push(`Lesson "${result.name}" (${result.lessonId}) must open with author-speak-before: frame the lesson, and after the first lesson start by recapping the previous one.`);
|
|
2020
|
+
}
|
|
2021
|
+
for (const result of results.filter((r) => r.rejectedActions.length > 0)) {
|
|
2022
|
+
reasons.push(`Lesson "${result.name}" (${result.lessonId}) uses actions lessons can't use; ${describeRejected(result.rejectedActions)}.`);
|
|
2023
|
+
}
|
|
2024
|
+
if (!allContinuous) reasons.push("Some lessons do not follow cleanly from the previous lesson (snapshot discontinuity).");
|
|
2025
|
+
if (!allMustCompileClean) reasons.push("Some lessons marked mustCompile do not compile cleanly.");
|
|
2026
|
+
if (finalLesson && !finalLesson.mustCompile) reasons.push("The final lesson must be marked mustCompile (all code should work by the end).");
|
|
2027
|
+
else if (!finalCompilesClean) reasons.push("The final lesson does not compile cleanly.");
|
|
2028
|
+
return { courseId, done, lessonCount: lessons.length, lessons: results, reasons };
|
|
2029
|
+
};
|
|
2030
|
+
var validateCourseTool = async (courseId) => ok(await validateCourse(courseId));
|
|
2031
|
+
var getCourseStatusTool = (courseId) => {
|
|
2032
|
+
const course = getCourse(courseId);
|
|
2033
|
+
return ok({
|
|
2034
|
+
courseId: course.id,
|
|
2035
|
+
name: course.name,
|
|
2036
|
+
primaryLanguage: course.primaryLanguage,
|
|
2037
|
+
lessonCount: course.lessons.length,
|
|
2038
|
+
lessons: course.lessons.map((l, i) => ({
|
|
2039
|
+
index: i,
|
|
2040
|
+
lessonId: l.id,
|
|
2041
|
+
name: l.name,
|
|
2042
|
+
actionCount: l.actions.length,
|
|
2043
|
+
// At the render page's default pauses, with narration estimated from word counts.
|
|
2044
|
+
estimatedSeconds: estimatedSeconds(l.actions),
|
|
2045
|
+
mustCompile: l.mustCompile,
|
|
2046
|
+
continuesFromPrevious: l.continuesFromPrevious,
|
|
2047
|
+
hasActions: l.actions.length > 0
|
|
2048
|
+
}))
|
|
2049
|
+
});
|
|
2050
|
+
};
|
|
2051
|
+
var exportCourseTool = (courseId) => {
|
|
2052
|
+
const course = exportCourse(courseId);
|
|
2053
|
+
const dir = path7.join(codevideoHome(), "exports");
|
|
2054
|
+
fs7.mkdirSync(dir, { recursive: true });
|
|
2055
|
+
const file = path7.join(dir, `${courseId.replace(/[^a-z0-9-_]+/gi, "_")}.json`);
|
|
2056
|
+
const json = JSON.stringify(course, null, 2);
|
|
2057
|
+
fs7.writeFileSync(file, json);
|
|
2058
|
+
return ok({
|
|
2059
|
+
courseId,
|
|
2060
|
+
path: file,
|
|
2061
|
+
lessonCount: course.lessons.length,
|
|
2062
|
+
bytes: Buffer.byteLength(json),
|
|
2063
|
+
message: "Exported to a file. Open or pass that path instead of reading the course into context."
|
|
2064
|
+
});
|
|
2065
|
+
};
|
|
2066
|
+
|
|
2067
|
+
// src/tools/renderTools.ts
|
|
2068
|
+
import os4 from "os";
|
|
2069
|
+
import fs8 from "fs";
|
|
2070
|
+
import path8 from "path";
|
|
2071
|
+
import { generateHtmlStringFromLesson } from "@fullstackcraftllc/codevideo-exporters";
|
|
2072
|
+
var ok2 = (data) => JSON.stringify(data, null, 2);
|
|
2073
|
+
var toILesson = (l) => ({
|
|
2074
|
+
id: l.id,
|
|
2075
|
+
name: l.name,
|
|
2076
|
+
description: l.description,
|
|
2077
|
+
actions: l.actions,
|
|
2078
|
+
initialSnapshot: l.initialSnapshot,
|
|
2079
|
+
finalSnapshot: l.finalSnapshot
|
|
2080
|
+
});
|
|
2081
|
+
var writePreviewHtml = async (courseId, l) => {
|
|
2082
|
+
const html = await generateHtmlStringFromLesson(toILesson(l), {});
|
|
2083
|
+
const outDir = path8.join(os4.homedir(), ".codevideo", "previews", courseId);
|
|
2084
|
+
fs8.mkdirSync(outDir, { recursive: true });
|
|
2085
|
+
const file = path8.join(outDir, `${l.id}.html`);
|
|
2086
|
+
fs8.writeFileSync(file, html, "utf8");
|
|
2087
|
+
return file;
|
|
2088
|
+
};
|
|
2089
|
+
var generateLessonPreviewTool = async (courseId, lessonId) => {
|
|
2090
|
+
const file = await writePreviewHtml(courseId, getLesson(courseId, lessonId));
|
|
2091
|
+
return ok2({ courseId, lessonId, previewHtml: file, message: "Open this HTML file in a browser to preview the lesson." });
|
|
2092
|
+
};
|
|
2093
|
+
var generateCoursePreviewsTool = async (courseId) => {
|
|
2094
|
+
const validation = await validateCourse(courseId);
|
|
2095
|
+
if (!validation.done) {
|
|
2096
|
+
return ok2({
|
|
2097
|
+
ok: false,
|
|
2098
|
+
gated: true,
|
|
2099
|
+
message: "Course has not passed validation yet \u2014 fix it first, then generate previews.",
|
|
2100
|
+
reasons: validation.reasons
|
|
2101
|
+
});
|
|
2102
|
+
}
|
|
2103
|
+
const previews = [];
|
|
2104
|
+
for (const l of listLessons(courseId)) {
|
|
2105
|
+
previews.push({ lessonId: l.id, name: l.name, previewHtml: await writePreviewHtml(courseId, l) });
|
|
2106
|
+
}
|
|
2107
|
+
return ok2({
|
|
2108
|
+
ok: true,
|
|
2109
|
+
courseId,
|
|
2110
|
+
previews,
|
|
2111
|
+
message: "Previews generated. Review them; once a human approves, call codevideo_render_course_videos to spawn per-lesson video rendering."
|
|
2112
|
+
});
|
|
2113
|
+
};
|
|
2114
|
+
var lessonSpec = (course, l, options, open) => ({
|
|
2115
|
+
kind: "course-lesson",
|
|
2116
|
+
courseId: course.id,
|
|
2117
|
+
lessonId: l.id,
|
|
2118
|
+
label: l.name || "lesson",
|
|
2119
|
+
project: toILesson(l),
|
|
2120
|
+
outputPath: newVideoPath(l.name || "lesson"),
|
|
2121
|
+
options,
|
|
2122
|
+
open,
|
|
2123
|
+
defaultLanguage: options.defaultLanguage ?? (course.primaryLanguage || "typescript")
|
|
2124
|
+
});
|
|
2125
|
+
var renderLessonVideoTool = async (courseId, lessonId, options = {}, open = true) => {
|
|
2126
|
+
const validation = await validateCourse(courseId);
|
|
2127
|
+
if (!validation.done) {
|
|
2128
|
+
return ok2({ ok: false, gated: true, message: "No video until the course passes validation. Fix the course first.", reasons: validation.reasons });
|
|
2129
|
+
}
|
|
2130
|
+
const course = getCourse(courseId);
|
|
2131
|
+
const lesson = getLesson(courseId, lessonId);
|
|
2132
|
+
const [job] = queueRenders([lessonSpec(course, lesson, options, open)]);
|
|
2133
|
+
return ok2({
|
|
2134
|
+
ok: true,
|
|
2135
|
+
...describeJob(job),
|
|
2136
|
+
estimatedSeconds: estimatedSeconds(lesson.actions, options),
|
|
2137
|
+
message: "Queued. It renders in the background; check it with codevideo_get_render_job."
|
|
2138
|
+
});
|
|
2139
|
+
};
|
|
2140
|
+
var renderCourseVideosTool = async (courseId, options = {}, open = false) => {
|
|
2141
|
+
const validation = await validateCourse(courseId);
|
|
2142
|
+
if (!validation.done) {
|
|
2143
|
+
return ok2({ ok: false, gated: true, message: "No video until the course passes validation. Fix the course first.", reasons: validation.reasons });
|
|
2144
|
+
}
|
|
2145
|
+
const course = getCourse(courseId);
|
|
2146
|
+
const jobs = queueRenders(course.lessons.map((l) => lessonSpec(course, l, options, open)));
|
|
2147
|
+
return ok2({
|
|
2148
|
+
ok: true,
|
|
2149
|
+
courseId,
|
|
2150
|
+
jobs: jobs.map((job, i) => ({
|
|
2151
|
+
jobId: job.id,
|
|
2152
|
+
lessonId: job.lessonId,
|
|
2153
|
+
label: job.label,
|
|
2154
|
+
outputPath: job.outputPath,
|
|
2155
|
+
estimatedSeconds: estimatedSeconds(course.lessons[i].actions, options)
|
|
2156
|
+
})),
|
|
2157
|
+
message: `Queued ${jobs.length} lesson video(s). They render one at a time in the background; check them with codevideo_list_render_jobs({ courseId }).`
|
|
2158
|
+
});
|
|
2159
|
+
};
|
|
2160
|
+
|
|
2161
|
+
// src/resources/get_action_names.ts
|
|
2162
|
+
var getActionNames = () => {
|
|
2163
|
+
return actionReference();
|
|
2164
|
+
};
|
|
2165
|
+
|
|
2166
|
+
// src/resources/get_example_actions_array_by_keyword.ts
|
|
2167
|
+
var keywordsToExampleActionsMap = [
|
|
2168
|
+
{
|
|
2169
|
+
keywords: ["console log", "javascript", "js", "log"],
|
|
2170
|
+
actions: [
|
|
2171
|
+
{
|
|
2172
|
+
name: "author-speak-before",
|
|
2173
|
+
value: "Today we're going to learn about console.log in JavaScript. Console.log is a function that allows you to print messages to the console, which is useful for debugging and understanding your code better."
|
|
2174
|
+
},
|
|
2175
|
+
{
|
|
2176
|
+
name: "author-speak-before",
|
|
2177
|
+
value: "Let's first create an index.js file where we will write our code."
|
|
2178
|
+
},
|
|
2179
|
+
{
|
|
2180
|
+
name: "mouse-move-file-explorer",
|
|
2181
|
+
value: "1"
|
|
2182
|
+
},
|
|
2183
|
+
{
|
|
2184
|
+
name: "mouse-right-click",
|
|
2185
|
+
value: "1"
|
|
2186
|
+
},
|
|
2187
|
+
{
|
|
2188
|
+
name: "mouse-move-file-explorer-context-menu-new-file",
|
|
2189
|
+
value: "1"
|
|
2190
|
+
},
|
|
2191
|
+
{
|
|
2192
|
+
name: "mouse-left-click",
|
|
2193
|
+
value: "1"
|
|
2194
|
+
}
|
|
2195
|
+
]
|
|
2196
|
+
},
|
|
2197
|
+
{
|
|
2198
|
+
keywords: ["golang", "go", "golang console log", "golang log"],
|
|
2199
|
+
actions: [
|
|
2200
|
+
{
|
|
2201
|
+
"name": "author-speak-before",
|
|
2202
|
+
"value": "Let's learn how to use the fmt.Println function in Go!"
|
|
2203
|
+
},
|
|
2204
|
+
{
|
|
2205
|
+
"name": "author-speak-before",
|
|
2206
|
+
"value": "Let's first make a main.go file."
|
|
2207
|
+
},
|
|
2208
|
+
{
|
|
2209
|
+
"name": "mouse-move-file-explorer",
|
|
2210
|
+
"value": "1"
|
|
2211
|
+
},
|
|
2212
|
+
{
|
|
2213
|
+
"name": "mouse-right-click",
|
|
2214
|
+
"value": "1"
|
|
2215
|
+
},
|
|
2216
|
+
{
|
|
2217
|
+
"name": "mouse-move-file-explorer-context-menu-new-file",
|
|
2218
|
+
"value": "1"
|
|
2219
|
+
},
|
|
2220
|
+
{
|
|
2221
|
+
"name": "mouse-left-click",
|
|
2222
|
+
"value": "1"
|
|
2223
|
+
},
|
|
2224
|
+
{
|
|
2225
|
+
"name": "file-explorer-type-new-file-input",
|
|
2226
|
+
"value": "main.go"
|
|
2227
|
+
},
|
|
2228
|
+
{
|
|
2229
|
+
"name": "file-explorer-enter-new-file-input",
|
|
2230
|
+
"value": "1"
|
|
2231
|
+
},
|
|
2232
|
+
{
|
|
2233
|
+
"name": "author-speak-before",
|
|
2234
|
+
"value": "First, we'll need to declare the main package, and then the main function."
|
|
2235
|
+
},
|
|
2236
|
+
{
|
|
2237
|
+
"name": "editor-type",
|
|
2238
|
+
"value": "package main"
|
|
2239
|
+
},
|
|
2240
|
+
{
|
|
2241
|
+
"name": "editor-enter",
|
|
2242
|
+
"value": "2"
|
|
2243
|
+
},
|
|
2244
|
+
{
|
|
2245
|
+
"name": "author-speak-before",
|
|
2246
|
+
"value": "And we'll need the fmt package to use the Println function."
|
|
2247
|
+
},
|
|
2248
|
+
{
|
|
2249
|
+
"name": "editor-type",
|
|
2250
|
+
"value": 'import "fmt"'
|
|
2251
|
+
},
|
|
2252
|
+
{
|
|
2253
|
+
"name": "editor-enter",
|
|
2254
|
+
"value": "1"
|
|
2255
|
+
},
|
|
2256
|
+
{
|
|
2257
|
+
"name": "editor-type",
|
|
2258
|
+
"value": "func main() {"
|
|
2259
|
+
},
|
|
2260
|
+
{
|
|
2261
|
+
"name": "editor-enter",
|
|
2262
|
+
"value": "1"
|
|
2263
|
+
},
|
|
2264
|
+
{
|
|
2265
|
+
"name": "author-speak-before",
|
|
2266
|
+
"value": "and let's just print 'Hello world!' to the console."
|
|
2267
|
+
},
|
|
2268
|
+
{
|
|
2269
|
+
"name": "editor-type",
|
|
2270
|
+
"value": ' fmt.Println("Hello, world!")'
|
|
2271
|
+
},
|
|
2272
|
+
{
|
|
2273
|
+
"name": "editor-enter",
|
|
2274
|
+
"value": "1"
|
|
2275
|
+
},
|
|
2276
|
+
{
|
|
2277
|
+
"name": "editor-type",
|
|
2278
|
+
"value": "}"
|
|
2279
|
+
},
|
|
2280
|
+
{
|
|
2281
|
+
"name": "editor-arrow-up",
|
|
2282
|
+
"value": "1"
|
|
2283
|
+
},
|
|
2284
|
+
{
|
|
2285
|
+
"name": "editor-command-right",
|
|
2286
|
+
"value": "1"
|
|
2287
|
+
},
|
|
2288
|
+
{
|
|
2289
|
+
"name": "author-speak-before",
|
|
2290
|
+
"value": "and if I wanted to write the value of some variable to the console, I could do that like so:"
|
|
2291
|
+
},
|
|
2292
|
+
{
|
|
2293
|
+
"name": "editor-backspace",
|
|
2294
|
+
"value": "32"
|
|
2295
|
+
},
|
|
2296
|
+
{
|
|
2297
|
+
"name": "editor-type",
|
|
2298
|
+
"value": " myVariable := 5"
|
|
2299
|
+
},
|
|
2300
|
+
{
|
|
2301
|
+
"name": "editor-enter",
|
|
2302
|
+
"value": "1"
|
|
2303
|
+
},
|
|
2304
|
+
{
|
|
2305
|
+
"name": "editor-type",
|
|
2306
|
+
"value": " fmt.Println(myVariable)"
|
|
2307
|
+
},
|
|
2308
|
+
{
|
|
2309
|
+
"name": "author-speak-before",
|
|
2310
|
+
"value": "Now, when I run this code, I would expect the value of 'myVariable' to be printed to the console. Something like:"
|
|
2311
|
+
},
|
|
2312
|
+
{
|
|
2313
|
+
"name": "editor-enter",
|
|
2314
|
+
"value": "1"
|
|
2315
|
+
},
|
|
2316
|
+
{
|
|
2317
|
+
"name": "editor-type",
|
|
2318
|
+
"value": " // 5"
|
|
2319
|
+
},
|
|
2320
|
+
{
|
|
2321
|
+
"name": "author-speak-before",
|
|
2322
|
+
"value": "Console logging is simple, yet powerful and very useful!"
|
|
2323
|
+
}
|
|
2324
|
+
]
|
|
2325
|
+
},
|
|
2326
|
+
{
|
|
2327
|
+
keywords: ["python", "python console log", "python log"],
|
|
2328
|
+
actions: [
|
|
2329
|
+
{
|
|
2330
|
+
"name": "author-speak-before",
|
|
2331
|
+
"value": "Let's learn how to use the print function in Python!"
|
|
2332
|
+
},
|
|
2333
|
+
{
|
|
2334
|
+
"name": "author-speak-before",
|
|
2335
|
+
"value": "First, let's make a main.py Python file."
|
|
2336
|
+
},
|
|
2337
|
+
{
|
|
2338
|
+
"name": "mouse-move-file-explorer",
|
|
2339
|
+
"value": "1"
|
|
2340
|
+
},
|
|
2341
|
+
{
|
|
2342
|
+
"name": "mouse-right-click",
|
|
2343
|
+
"value": "1"
|
|
2344
|
+
},
|
|
2345
|
+
{
|
|
2346
|
+
"name": "mouse-move-file-explorer-context-menu-new-file",
|
|
2347
|
+
"value": "1"
|
|
2348
|
+
},
|
|
2349
|
+
{
|
|
2350
|
+
"name": "mouse-left-click",
|
|
2351
|
+
"value": "1"
|
|
2352
|
+
},
|
|
2353
|
+
{
|
|
2354
|
+
"name": "file-explorer-type-new-file-input",
|
|
2355
|
+
"value": "main.py"
|
|
2356
|
+
},
|
|
2357
|
+
{
|
|
2358
|
+
"name": "file-explorer-enter-new-file-input",
|
|
2359
|
+
"value": "1"
|
|
2360
|
+
},
|
|
2361
|
+
{
|
|
2362
|
+
"name": "author-speak-before",
|
|
2363
|
+
"value": "and let's print 'Hello world!' to the console."
|
|
2364
|
+
},
|
|
2365
|
+
{
|
|
2366
|
+
"name": "editor-type",
|
|
2367
|
+
"value": "print('Hello, world!')"
|
|
2368
|
+
},
|
|
2369
|
+
{
|
|
2370
|
+
"name": "author-speak-before",
|
|
2371
|
+
"value": "and if I wanted to write the value of some variable to the console, I could do that like so:"
|
|
2372
|
+
},
|
|
2373
|
+
{
|
|
2374
|
+
"name": "editor-backspace",
|
|
2375
|
+
"value": "22"
|
|
2376
|
+
},
|
|
2377
|
+
{
|
|
2378
|
+
"name": "editor-type",
|
|
2379
|
+
"value": "my_variable = 5"
|
|
2380
|
+
},
|
|
2381
|
+
{
|
|
2382
|
+
"name": "editor-enter",
|
|
2383
|
+
"value": "1"
|
|
2384
|
+
},
|
|
2385
|
+
{
|
|
2386
|
+
"name": "editor-type",
|
|
2387
|
+
"value": "print(my_variable)"
|
|
2388
|
+
},
|
|
2389
|
+
{
|
|
2390
|
+
"name": "author-speak-before",
|
|
2391
|
+
"value": "Now, when I run this code, I would expect the value of 'my_variable' to be printed to the console. Something like:"
|
|
2392
|
+
},
|
|
2393
|
+
{
|
|
2394
|
+
"name": "editor-enter",
|
|
2395
|
+
"value": "1"
|
|
2396
|
+
},
|
|
2397
|
+
{
|
|
2398
|
+
"name": "editor-type",
|
|
2399
|
+
"value": "# 5"
|
|
2400
|
+
},
|
|
2401
|
+
{
|
|
2402
|
+
"name": "author-speak-before",
|
|
2403
|
+
"value": "Console logging is simple, yet powerful and very useful!"
|
|
2404
|
+
},
|
|
2405
|
+
{
|
|
2406
|
+
"name": "author-speak-before",
|
|
2407
|
+
"value": "Well done, lets save our file."
|
|
2408
|
+
},
|
|
2409
|
+
{
|
|
2410
|
+
"name": "editor-save",
|
|
2411
|
+
"value": "main.py"
|
|
2412
|
+
},
|
|
2413
|
+
{
|
|
2414
|
+
"name": "author-speak-before",
|
|
2415
|
+
"value": "Saved!"
|
|
2416
|
+
},
|
|
2417
|
+
{
|
|
2418
|
+
"name": "author-speak-before",
|
|
2419
|
+
"value": "Oh... and I've just realized... this file looks a bit strange... let's add the python shebang to the top of the file."
|
|
2420
|
+
},
|
|
2421
|
+
{
|
|
2422
|
+
"name": "editor-arrow-up",
|
|
2423
|
+
"value": "4"
|
|
2424
|
+
},
|
|
2425
|
+
{
|
|
2426
|
+
"name": "editor-command-left",
|
|
2427
|
+
"value": "1"
|
|
2428
|
+
},
|
|
2429
|
+
{
|
|
2430
|
+
"name": "editor-enter",
|
|
2431
|
+
"value": "1"
|
|
2432
|
+
},
|
|
2433
|
+
{
|
|
2434
|
+
"name": "editor-arrow-up",
|
|
2435
|
+
"value": "1"
|
|
2436
|
+
},
|
|
2437
|
+
{
|
|
2438
|
+
"name": "editor-type",
|
|
2439
|
+
"value": "#!/usr/bin/env python"
|
|
2440
|
+
},
|
|
2441
|
+
{
|
|
2442
|
+
"name": "editor-enter",
|
|
2443
|
+
"value": "1"
|
|
2444
|
+
},
|
|
2445
|
+
{
|
|
2446
|
+
"name": "author-speak-before",
|
|
2447
|
+
"value": "Ah...much better!"
|
|
2448
|
+
},
|
|
2449
|
+
{
|
|
2450
|
+
"name": "author-speak-before",
|
|
2451
|
+
"value": "Let's save the file again."
|
|
2452
|
+
},
|
|
2453
|
+
{
|
|
2454
|
+
"name": "editor-save",
|
|
2455
|
+
"value": "main.py"
|
|
2456
|
+
},
|
|
2457
|
+
{
|
|
2458
|
+
"name": "author-speak-before",
|
|
2459
|
+
"value": "Saved!"
|
|
2460
|
+
},
|
|
2461
|
+
{
|
|
2462
|
+
"name": "author-speak-before",
|
|
2463
|
+
"value": "Now, let's open a terminal and run this file..."
|
|
2464
|
+
},
|
|
2465
|
+
{
|
|
2466
|
+
"name": "terminal-open",
|
|
2467
|
+
"value": "1"
|
|
2468
|
+
},
|
|
2469
|
+
{
|
|
2470
|
+
"name": "terminal-type",
|
|
2471
|
+
"value": "python main.py"
|
|
2472
|
+
},
|
|
2473
|
+
{
|
|
2474
|
+
"name": "terminal-set-output",
|
|
2475
|
+
"value": "5"
|
|
2476
|
+
},
|
|
2477
|
+
{
|
|
2478
|
+
"name": "terminal-enter",
|
|
2479
|
+
"value": "1"
|
|
2480
|
+
},
|
|
2481
|
+
{
|
|
2482
|
+
"name": "author-speak-before",
|
|
2483
|
+
"value": "Perfect, our code is working as expected! I hope you enjoyed this lesson!"
|
|
2484
|
+
}
|
|
2485
|
+
]
|
|
2486
|
+
}
|
|
2487
|
+
];
|
|
2488
|
+
var getExampleActionsArrayByKeyword = (keyword) => {
|
|
2489
|
+
const exampleJSONs = [];
|
|
2490
|
+
if (Array.isArray(keyword)) {
|
|
2491
|
+
for (var i = 0; i < keyword.length; i++) {
|
|
2492
|
+
const exampleJSON = getExampleActionsArrayByKeyword(keyword[i]);
|
|
2493
|
+
exampleJSONs.push(exampleJSON);
|
|
2494
|
+
}
|
|
2495
|
+
return exampleJSONs.join(", ");
|
|
2496
|
+
} else {
|
|
2497
|
+
const exampleJSON = getSingleExampleActionsArray(keyword);
|
|
2498
|
+
if (exampleJSON) {
|
|
2499
|
+
return exampleJSON;
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
return "";
|
|
2503
|
+
};
|
|
2504
|
+
var getSingleExampleActionsArray = (keyword) => {
|
|
2505
|
+
for (var i = 0; i < keywordsToExampleActionsMap.length; i++) {
|
|
2506
|
+
const keywordMap = keywordsToExampleActionsMap[i];
|
|
2507
|
+
if (keywordMap.keywords.includes(keyword)) {
|
|
2508
|
+
return JSON.stringify(keywordMap.actions);
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
return "";
|
|
2512
|
+
};
|
|
2513
|
+
|
|
2514
|
+
// src/resources/get_example_lessons_by_keyword.ts
|
|
2515
|
+
var keywordsToExampleLessonsMap = [
|
|
2516
|
+
{
|
|
2517
|
+
keywords: ["go", "golang", "explore codebase", "existing codebase", "go cli tool"],
|
|
2518
|
+
lesson: {
|
|
2519
|
+
"id": "",
|
|
2520
|
+
"name": "",
|
|
2521
|
+
"description": "",
|
|
2522
|
+
"actions": [
|
|
2523
|
+
{
|
|
2524
|
+
"name": "author-speak-before",
|
|
2525
|
+
"value": "Hi there! Let's explore this interesting Golang repository that helps track GitLab contributions. This tool analyzes user activity across different time periods and produces contribution statistics."
|
|
2526
|
+
},
|
|
2527
|
+
{
|
|
2528
|
+
"name": "mouse-move-file-explorer",
|
|
2529
|
+
"value": "1"
|
|
2530
|
+
},
|
|
2531
|
+
{
|
|
2532
|
+
"name": "mouse-move-file-explorer-file",
|
|
2533
|
+
"value": "main.go"
|
|
2534
|
+
},
|
|
2535
|
+
{
|
|
2536
|
+
"name": "mouse-left-click",
|
|
2537
|
+
"value": "1"
|
|
2538
|
+
},
|
|
2539
|
+
{
|
|
2540
|
+
"name": "author-speak-before",
|
|
2541
|
+
"value": "The main.go file is the entry point for the application. It defines the core functionality for tracking GitLab contributions."
|
|
2542
|
+
},
|
|
2543
|
+
{
|
|
2544
|
+
"name": "mouse-move-to-coordinates-percent",
|
|
2545
|
+
"value": "50,20"
|
|
2546
|
+
},
|
|
2547
|
+
{
|
|
2548
|
+
"name": "author-speak-before",
|
|
2549
|
+
"value": "This struct stores contribution statistics for a user - tracking both commits and merge requests across different time periods."
|
|
2550
|
+
},
|
|
2551
|
+
{
|
|
2552
|
+
"name": "author-speak-before",
|
|
2553
|
+
"value": "The main function initializes the application by loading environment variables, getting the GitLab token, and reading usernames from a file."
|
|
2554
|
+
},
|
|
2555
|
+
{
|
|
2556
|
+
"name": "mouse-move-file-explorer-file",
|
|
2557
|
+
"value": "usernames.txt"
|
|
2558
|
+
},
|
|
2559
|
+
{
|
|
2560
|
+
"name": "mouse-left-click",
|
|
2561
|
+
"value": "1"
|
|
2562
|
+
},
|
|
2563
|
+
{
|
|
2564
|
+
"name": "author-speak-before",
|
|
2565
|
+
"value": "The usernames.txt file contains the list of GitLab users whose contributions we want to track."
|
|
2566
|
+
},
|
|
2567
|
+
{
|
|
2568
|
+
"name": "mouse-move-file-explorer-file",
|
|
2569
|
+
"value": "main.go"
|
|
2570
|
+
},
|
|
2571
|
+
{
|
|
2572
|
+
"name": "mouse-left-click",
|
|
2573
|
+
"value": "1"
|
|
2574
|
+
},
|
|
2575
|
+
{
|
|
2576
|
+
"name": "file-explorer-open-file",
|
|
2577
|
+
"value": "main.go"
|
|
2578
|
+
},
|
|
2579
|
+
{
|
|
2580
|
+
"name": "author-speak-before",
|
|
2581
|
+
"value": "This function calculates contribution statistics for a single user. It defines time windows for different periods - daily, weekly, monthly, and yearly."
|
|
2582
|
+
},
|
|
2583
|
+
{
|
|
2584
|
+
"name": "author-speak-before",
|
|
2585
|
+
"value": "The GetUserActivity function connects to the GitLab API to fetch user events within a specified time period."
|
|
2586
|
+
},
|
|
2587
|
+
{
|
|
2588
|
+
"name": "author-speak-before",
|
|
2589
|
+
"value": "The printResults function formats and displays the contribution statistics, including calculating metrics like average commits per merge request."
|
|
2590
|
+
},
|
|
2591
|
+
{
|
|
2592
|
+
"name": "mouse-move-file-explorer",
|
|
2593
|
+
"value": "1"
|
|
2594
|
+
},
|
|
2595
|
+
{
|
|
2596
|
+
"name": "mouse-move-file-explorer-folder",
|
|
2597
|
+
"value": "http_helper"
|
|
2598
|
+
},
|
|
2599
|
+
{
|
|
2600
|
+
"name": "mouse-left-click",
|
|
2601
|
+
"value": "1"
|
|
2602
|
+
},
|
|
2603
|
+
{
|
|
2604
|
+
"name": "file-explorer-expand-folder",
|
|
2605
|
+
"value": "http_helper"
|
|
2606
|
+
},
|
|
2607
|
+
{
|
|
2608
|
+
"name": "mouse-move-file-explorer-file",
|
|
2609
|
+
"value": "http_helper/http_helper.go"
|
|
2610
|
+
},
|
|
2611
|
+
{
|
|
2612
|
+
"name": "mouse-left-click",
|
|
2613
|
+
"value": "1"
|
|
2614
|
+
},
|
|
2615
|
+
{
|
|
2616
|
+
"name": "file-explorer-open-file",
|
|
2617
|
+
"value": "http_helper/http_helper.go"
|
|
2618
|
+
},
|
|
2619
|
+
{
|
|
2620
|
+
"name": "author-speak-before",
|
|
2621
|
+
"value": "The project has a dedicated HTTP helper module that handles API requests to GitLab."
|
|
2622
|
+
},
|
|
2623
|
+
{
|
|
2624
|
+
"name": "editor-highlight-code",
|
|
2625
|
+
"value": "func MakeHTTPRequest(fullUrl string, httpMethod string, headers map[string]string, queryParameters url.Values, body io.Reader) ([]byte, error) {"
|
|
2626
|
+
},
|
|
2627
|
+
{
|
|
2628
|
+
"name": "author-speak-before",
|
|
2629
|
+
"value": "This function handles standard HTTP requests, while the function below uses generics for typed responses."
|
|
2630
|
+
},
|
|
2631
|
+
{
|
|
2632
|
+
"name": "editor-highlight-code",
|
|
2633
|
+
"value": "func MakeHTTPRequestGeneric[T any](fullUrl string, httpMethod string, headers map[string]string, queryParameters url.Values, body io.Reader, responseType T) (T, error) {"
|
|
2634
|
+
},
|
|
2635
|
+
{
|
|
2636
|
+
"name": "author-speak-before",
|
|
2637
|
+
"value": "MakeHTTPRequestGeneric is a modern Go function using generics to handle typed API responses, making it easier to work with the GitLab API."
|
|
2638
|
+
},
|
|
2639
|
+
{
|
|
2640
|
+
"name": "mouse-move-file-explorer-file",
|
|
2641
|
+
"value": "main_test.go"
|
|
2642
|
+
},
|
|
2643
|
+
{
|
|
2644
|
+
"name": "mouse-left-click",
|
|
2645
|
+
"value": "1"
|
|
2646
|
+
},
|
|
2647
|
+
{
|
|
2648
|
+
"name": "file-explorer-open-file",
|
|
2649
|
+
"value": "main_test.go"
|
|
2650
|
+
},
|
|
2651
|
+
{
|
|
2652
|
+
"name": "author-speak-before",
|
|
2653
|
+
"value": "The project also includes tests to verify the functionality of the GitLab API interactions."
|
|
2654
|
+
},
|
|
2655
|
+
{
|
|
2656
|
+
"name": "mouse-move-file-explorer-file",
|
|
2657
|
+
"value": ".env"
|
|
2658
|
+
},
|
|
2659
|
+
{
|
|
2660
|
+
"name": "mouse-left-click",
|
|
2661
|
+
"value": "1"
|
|
2662
|
+
},
|
|
2663
|
+
{
|
|
2664
|
+
"name": "file-explorer-open-file",
|
|
2665
|
+
"value": ".env"
|
|
2666
|
+
},
|
|
2667
|
+
{
|
|
2668
|
+
"name": "author-speak-before",
|
|
2669
|
+
"value": "The .env file stores the GitLab token needed to authenticate with the API."
|
|
2670
|
+
},
|
|
2671
|
+
{
|
|
2672
|
+
"name": "mouse-move-file-explorer-file",
|
|
2673
|
+
"value": "go.mod"
|
|
2674
|
+
},
|
|
2675
|
+
{
|
|
2676
|
+
"name": "mouse-left-click",
|
|
2677
|
+
"value": "1"
|
|
2678
|
+
},
|
|
2679
|
+
{
|
|
2680
|
+
"name": "file-explorer-open-file",
|
|
2681
|
+
"value": "go.mod"
|
|
2682
|
+
},
|
|
2683
|
+
{
|
|
2684
|
+
"name": "author-speak-before",
|
|
2685
|
+
"value": "The go.mod file lists the project dependencies, including the go-gitlab library for API interaction and godotenv for environment variable loading."
|
|
2686
|
+
},
|
|
2687
|
+
{
|
|
2688
|
+
"name": "mouse-move-file-explorer-file",
|
|
2689
|
+
"value": "main.go"
|
|
2690
|
+
},
|
|
2691
|
+
{
|
|
2692
|
+
"name": "mouse-left-click",
|
|
2693
|
+
"value": "1"
|
|
2694
|
+
},
|
|
2695
|
+
{
|
|
2696
|
+
"name": "file-explorer-open-file",
|
|
2697
|
+
"value": "main.go"
|
|
2698
|
+
},
|
|
2699
|
+
{
|
|
2700
|
+
"name": "author-speak-after",
|
|
2701
|
+
"value": "To summarize, this Go repository is a GitLab contribution analyzer that fetches user activity data from GitLab's API. It tracks commits and merge requests across multiple time periods, calculates stats, and outputs nicely formatted results with progress bars. It's designed to help track team productivity and identify top contributors. The code is well-structured with separate modules for HTTP requests and includes proper error handling and testing."
|
|
2702
|
+
}
|
|
2703
|
+
],
|
|
2704
|
+
"initialSnapshot": {
|
|
2705
|
+
"isUnsavedChangesDialogOpen": false,
|
|
2706
|
+
"unsavedFileName": "",
|
|
2707
|
+
"fileExplorerSnapshot": {
|
|
2708
|
+
"isFileExplorerContextMenuOpen": false,
|
|
2709
|
+
"isFileContextMenuOpen": false,
|
|
2710
|
+
"isFolderContextMenuOpen": false,
|
|
2711
|
+
"isNewFileInputVisible": false,
|
|
2712
|
+
"isNewFolderInputVisible": false,
|
|
2713
|
+
"isRenameFileInputVisible": false,
|
|
2714
|
+
"isRenameFolderInputVisible": false,
|
|
2715
|
+
"newFileInputValue": "",
|
|
2716
|
+
"newFolderInputValue": "",
|
|
2717
|
+
"renameFileInputValue": "",
|
|
2718
|
+
"renameFolderInputValue": "",
|
|
2719
|
+
"originalFileBeingRenamed": "",
|
|
2720
|
+
"originalFolderBeingRenamed": "",
|
|
2721
|
+
"newFileParentPath": "",
|
|
2722
|
+
"newFolderParentPath": "",
|
|
2723
|
+
"fileStructure": {
|
|
2724
|
+
".env": {
|
|
2725
|
+
"type": "file",
|
|
2726
|
+
"content": "GITLAB_TOKEN=glpat-uShSYUHwpUcDxfkGcoNL",
|
|
2727
|
+
"language": "plaintext",
|
|
2728
|
+
"caretPosition": {
|
|
2729
|
+
"row": 0,
|
|
2730
|
+
"col": 0
|
|
2731
|
+
}
|
|
2732
|
+
},
|
|
2733
|
+
".gitignore": {
|
|
2734
|
+
"type": "file",
|
|
2735
|
+
"content": ".env",
|
|
2736
|
+
"language": "plaintext",
|
|
2737
|
+
"caretPosition": {
|
|
2738
|
+
"row": 0,
|
|
2739
|
+
"col": 0
|
|
2740
|
+
}
|
|
2741
|
+
},
|
|
2742
|
+
"go.mod": {
|
|
2743
|
+
"type": "file",
|
|
2744
|
+
"content": "module solve.ch/solvers\n\ngo 1.23.0\n\nrequire github.com/xanzy/go-gitlab v0.115.0\n\nrequire (\n github.com/golang/protobuf v1.5.3 // indirect\n github.com/google/go-querystring v1.1.0 // indirect\n github.com/hashicorp/go-cleanhttp v0.5.2 // indirect\n github.com/hashicorp/go-retryablehttp v0.7.7 // indirect\n github.com/joho/godotenv v1.5.1\n gitlab.com/gitlab-org/api/client-go v0.120.0\n golang.org/x/net v0.8.0 // indirect\n golang.org/x/oauth2 v0.24.0 // indirect\n golang.org/x/time v0.8.0 // indirect\n google.golang.org/appengine v1.6.7 // indirect\n google.golang.org/protobuf v1.29.1 // indirect\n)\n",
|
|
2745
|
+
"language": "plaintext",
|
|
2746
|
+
"caretPosition": {
|
|
2747
|
+
"row": 0,
|
|
2748
|
+
"col": 0
|
|
2749
|
+
}
|
|
2750
|
+
},
|
|
2751
|
+
"go.sum": {
|
|
2752
|
+
"type": "file",
|
|
2753
|
+
"content": "github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=\ngithub.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=\ngithub.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=\ngithub.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=\ngithub.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=\ngithub.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=\ngithub.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=\ngithub.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=\ngithub.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=\ngithub.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=\ngithub.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=\ngithub.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=\ngithub.com/xanzy/go-gitlab v0.115.0 h1:6DmtItNcVe+At/liXSgfE/DZNZrGfalQmBRmOcJjOn8=\ngithub.com/xanzy/go-gitlab v0.115.0/go.mod h1:5XCDtM7AM6WMKmfDdOiEpyRWUqui2iS9ILfvCZ2gJ5M=\ngitlab.com/gitlab-org/api/client-go v0.120.0 h1:geCJjojDXxWVmUcTxPcOUCenAWElWB5dVfX3HJGeAMc=\ngitlab.com/gitlab-org/api/client-go v0.120.0/go.mod h1:ygHmS3AU3TpvK+AC6DYO1QuAxLlv6yxYK+/Votr/WFQ=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=\ngolang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=\ngolang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=\ngolang.org/x/oauth2 v0.6.0 h1:Lh8GPgSKBfWSwFvtuWOfeI3aAAnbXTSutYxJiOJFgIw=\ngolang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw=\ngolang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=\ngolang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=\ngolang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=\ngolang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=\ngolang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=\ngolang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngoogle.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=\ngoogle.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=\ngoogle.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=\ngoogle.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=\ngoogle.golang.org/protobuf v1.29.1 h1:7QBf+IK2gx70Ap/hDsOmam3GE0v9HicjfEdAxE62UoM=\ngoogle.golang.org/protobuf v1.29.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=\n",
|
|
2754
|
+
"language": "plaintext",
|
|
2755
|
+
"caretPosition": {
|
|
2756
|
+
"row": 0,
|
|
2757
|
+
"col": 0
|
|
2758
|
+
}
|
|
2759
|
+
},
|
|
2760
|
+
"http_helper": {
|
|
2761
|
+
"type": "directory",
|
|
2762
|
+
"content": "",
|
|
2763
|
+
"collapsed": false,
|
|
2764
|
+
"children": {
|
|
2765
|
+
"http_helper.go": {
|
|
2766
|
+
"type": "file",
|
|
2767
|
+
"content": `package http_helper
|
|
2768
|
+
|
|
2769
|
+
import (
|
|
2770
|
+
"encoding/json"
|
|
2771
|
+
"fmt"
|
|
2772
|
+
"io"
|
|
2773
|
+
"log"
|
|
2774
|
+
"net/http"
|
|
2775
|
+
"net/url"
|
|
2776
|
+
"strings"
|
|
2777
|
+
)
|
|
2778
|
+
|
|
2779
|
+
// in the case of GET, the parameter queryParameters is transferred to the URL as query parameters
|
|
2780
|
+
// in the case of POST, the parameter body, an io.Reader, is used
|
|
2781
|
+
func MakeHTTPRequest(fullUrl string, httpMethod string, headers map[string]string, queryParameters url.Values, body io.Reader) ([]byte, error) {
|
|
2782
|
+
client := http.Client{}
|
|
2783
|
+
u, err := url.Parse(fullUrl)
|
|
2784
|
+
if err != nil {
|
|
2785
|
+
return nil, err
|
|
2786
|
+
}
|
|
2787
|
+
|
|
2788
|
+
// if it's a GET, we need to append the query parameters.
|
|
2789
|
+
if httpMethod == "GET" {
|
|
2790
|
+
q := u.Query()
|
|
2791
|
+
|
|
2792
|
+
for k, v := range queryParameters {
|
|
2793
|
+
// this depends on the type of api, you may need to do it for each of v
|
|
2794
|
+
q.Set(k, strings.Join(v, ","))
|
|
2795
|
+
}
|
|
2796
|
+
// set the query to the encoded parameters
|
|
2797
|
+
u.RawQuery = q.Encode()
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
// regardless of GET or POST, we can safely add the body
|
|
2801
|
+
req, err := http.NewRequest(httpMethod, u.String(), body)
|
|
2802
|
+
if err != nil {
|
|
2803
|
+
return nil, err
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2806
|
+
// for each header passed, add the header value to the request
|
|
2807
|
+
for k, v := range headers {
|
|
2808
|
+
req.Header.Set(k, v)
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
// optional: log the request for easier stack tracing
|
|
2812
|
+
log.Printf("%s %s\\n", httpMethod, req.URL.String())
|
|
2813
|
+
|
|
2814
|
+
// finally, do the request
|
|
2815
|
+
res, err := client.Do(req)
|
|
2816
|
+
if err != nil {
|
|
2817
|
+
return nil, err
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
if res == nil {
|
|
2821
|
+
return nil, fmt.Errorf("error: calling %s returned empty response", u.String())
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
responseData, err := io.ReadAll(res.Body)
|
|
2825
|
+
if err != nil {
|
|
2826
|
+
return nil, err
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
defer res.Body.Close()
|
|
2830
|
+
|
|
2831
|
+
if res.StatusCode != http.StatusOK {
|
|
2832
|
+
return nil, fmt.Errorf("error calling %s:\\nstatus: %s\\nresponseData: %s", u.String(), res.Status, responseData)
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
return responseData, nil
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
func MakeHTTPRequestGeneric[T any](fullUrl string, httpMethod string, headers map[string]string, queryParameters url.Values, body io.Reader, responseType T) (T, error) {
|
|
2839
|
+
client := http.Client{}
|
|
2840
|
+
u, err := url.Parse(fullUrl)
|
|
2841
|
+
if err != nil {
|
|
2842
|
+
return responseType, err
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
// if it's a GET, we need to append the query parameters.
|
|
2846
|
+
if httpMethod == "GET" {
|
|
2847
|
+
q := u.Query()
|
|
2848
|
+
|
|
2849
|
+
for k, v := range queryParameters {
|
|
2850
|
+
// this depends on the type of api, you may need to do it for each of v
|
|
2851
|
+
q.Set(k, strings.Join(v, ","))
|
|
2852
|
+
}
|
|
2853
|
+
// set the query to the encoded parameters
|
|
2854
|
+
u.RawQuery = q.Encode()
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
// regardless of GET or POST, we can safely add the body
|
|
2858
|
+
req, err := http.NewRequest(httpMethod, u.String(), body)
|
|
2859
|
+
if err != nil {
|
|
2860
|
+
return responseType, err
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2863
|
+
// for each header passed, add the header value to the request
|
|
2864
|
+
for k, v := range headers {
|
|
2865
|
+
req.Header.Set(k, v)
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
// optional: log the request for easier stack tracing
|
|
2869
|
+
log.Printf("%s %s\\n", httpMethod, req.URL.String())
|
|
2870
|
+
|
|
2871
|
+
// finally, do the request
|
|
2872
|
+
res, err := client.Do(req)
|
|
2873
|
+
if err != nil {
|
|
2874
|
+
return responseType, err
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2877
|
+
if res == nil {
|
|
2878
|
+
return responseType, fmt.Errorf("error: calling %s returned empty response", u.String())
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
responseData, err := io.ReadAll(res.Body)
|
|
2882
|
+
if err != nil {
|
|
2883
|
+
return responseType, err
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2886
|
+
defer res.Body.Close()
|
|
2887
|
+
|
|
2888
|
+
if res.StatusCode != http.StatusOK {
|
|
2889
|
+
return responseType, fmt.Errorf("error calling %s:\\nstatus: %s\\nresponseData: %s", u.String(), res.Status, responseData)
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
var responseObject T
|
|
2893
|
+
err = json.Unmarshal(responseData, &responseObject)
|
|
2894
|
+
|
|
2895
|
+
if err != nil {
|
|
2896
|
+
log.Printf("error unmarshaling response: %+v", err)
|
|
2897
|
+
return responseType, err
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
return responseObject, nil
|
|
2901
|
+
}
|
|
2902
|
+
`,
|
|
2903
|
+
"language": "Go",
|
|
2904
|
+
"caretPosition": {
|
|
2905
|
+
"row": 0,
|
|
2906
|
+
"col": 0
|
|
2907
|
+
}
|
|
2908
|
+
}
|
|
2909
|
+
}
|
|
2910
|
+
},
|
|
2911
|
+
"main.go": {
|
|
2912
|
+
"type": "file",
|
|
2913
|
+
"content": 'package main\n\nimport (\n "bufio"\n "fmt"\n "log"\n "os"\n "sort"\n "strings"\n "time"\n\n "github.com/joho/godotenv"\n "solve.ch/solvers/http_helper"\n)\n\ntype ContributionStats struct {\n Username string\n DailyCommits int\n WeeklyCommits int\n MonthlyCommits int\n YearlyCommits int\n DailyMRs int\n WeeklyMRs int\n MonthlyMRs int\n YearlyMRs int\n // New field to store total pages fetched from GitLab\n PagesFetched int\n}\n\ntype TimePeriod struct {\n Start time.Time\n End time.Time\n}\n\ntype GitLabAuthor struct {\n ID int `json:"id"`\n Username string `json:"username"`\n Name string `json:"name"`\n State string `json:"state"`\n AvatarURL string `json:"avatar_url"`\n WebURL string `json:"web_url"`\n}\n\ntype GitLabPushData struct {\n CommitCount int `json:"commit_count"`\n Action string `json:"action"`\n RefType string `json:"ref_type"`\n CommitFrom string `json:"commit_from"`\n CommitTo string `json:"commit_to"`\n Ref string `json:"ref"`\n CommitTitle string `json:"commit_title"`\n RefCount *int `json:"ref_count"`\n}\n\ntype GitLabEvent struct {\n ID int `json:"id"`\n ProjectID int `json:"project_id"`\n ActionName string `json:"action_name"`\n TargetID *int `json:"target_id"`\n TargetIID *int `json:"target_iid"`\n TargetType *string `json:"target_type"`\n AuthorID int `json:"author_id"`\n TargetTitle *string `json:"target_title"`\n CreatedAt time.Time `json:"created_at"`\n Author GitLabAuthor `json:"author"`\n PushData *GitLabPushData `json:"push_data"`\n}\n\nfunc main() {\n // Load .env file\n err := godotenv.Load()\n if err != nil {\n log.Println("Warning: Error loading .env file:", err)\n }\n\n // Replace with your GitLab token\n token := os.Getenv("GITLAB_TOKEN")\n if token == "" {\n log.Fatal("GITLAB_TOKEN environment variable not set")\n }\n\n // Read usernames from file\n usernames, err := readUsernames("usernames.txt")\n if err != nil {\n log.Fatalf("Failed to read usernames: %v", err)\n }\n\n // Get contribution stats for each user\n stats := make([]ContributionStats, 0)\n for _, username := range usernames {\n stat, err := getContributionStats(token, username)\n if err != nil {\n log.Printf("Warning: Failed to get stats for %s: %v", username, err)\n continue\n }\n stats = append(stats, stat)\n }\n\n // Sort by yearly commits (could be modified to use different metrics)\n sort.Slice(stats, func(i, j int) bool {\n return stats[i].YearlyCommits > stats[j].YearlyCommits\n })\n\n // Print results with normalized bars\n printResults(stats)\n}\n\nfunc readUsernames(filename string) ([]string, error) {\n file, err := os.Open(filename)\n if err != nil {\n return nil, err\n }\n defer file.Close()\n\n var usernames []string\n scanner := bufio.NewScanner(file)\n for scanner.Scan() {\n username := strings.TrimSpace(scanner.Text())\n if username != "" {\n usernames = append(usernames, username)\n }\n }\n return usernames, scanner.Err()\n}\n\nfunc getContributionStats(token, username string) (ContributionStats, error) {\n stats := ContributionStats{Username: username}\n\n // Define time windows for daily, weekly, monthly, yearly\n todayStart := time.Now().Truncate(24 * time.Hour)\n todayEnd := todayStart.Add(24 * time.Hour)\n\n // last 7 days (including today)\n weekStart := todayStart.AddDate(0, 0, -6)\n weekEnd := todayEnd\n\n // last 1 month\n monthStart := todayStart.AddDate(0, -1, 0)\n monthEnd := todayEnd\n\n // last 1 year\n yearStart := todayStart.AddDate(-1, 0, 0)\n yearEnd := todayEnd\n\n periods := map[string]TimePeriod{\n "daily": {\n Start: todayStart,\n End: todayEnd,\n },\n "weekly": {\n Start: weekStart,\n End: weekEnd,\n },\n "monthly": {\n Start: monthStart,\n End: monthEnd,\n },\n "yearly": {\n Start: yearStart,\n End: yearEnd,\n },\n }\n\n // We\'ll track how many pages we fetched total for each user\n totalPages := 0\n\n // Get user\'s events for each time period\n for period, timeRange := range periods {\n commits, mrs, pages, err := GetUserActivity(token, username, timeRange.Start, timeRange.End)\n if err != nil {\n return stats, err\n }\n // Accumulate total page count\n totalPages += pages\n\n switch period {\n case "daily":\n stats.DailyCommits = commits\n stats.DailyMRs = mrs\n case "weekly":\n stats.WeeklyCommits = commits\n stats.WeeklyMRs = mrs\n case "monthly":\n stats.MonthlyCommits = commits\n stats.MonthlyMRs = mrs\n case "yearly":\n stats.YearlyCommits = commits\n stats.YearlyMRs = mrs\n }\n }\n\n // Store the total pages fetched in stats\n stats.PagesFetched = totalPages\n\n return stats, nil\n}\n\n// GetUserActivity returns the total commits, MRs, and the number of pages fetched\nfunc GetUserActivity(token, username string, start, end time.Time) (commits int, mrs int, totalPages int, err error) {\n baseURL := "https://git.solve.ch/api/v4/users"\n // We will iterate over pages, so exclude &page from the base URL:\n fullURL := fmt.Sprintf(\n "%s/%s/events?after=%s&before=%s&per_page=100",\n baseURL,\n username,\n start.Format("2006-01-02"),\n end.Format("2006-01-02"),\n )\n\n headers := map[string]string{\n "PRIVATE-TOKEN": token,\n }\n\n var allEvents []GitLabEvent\n page := 1\n\n for {\n // Construct URL for the current page\n pagedURL := fmt.Sprintf("%s&page=%d", fullURL, page)\n\n var tempEvents []GitLabEvent\n tempEvents, err = http_helper.MakeHTTPRequestGeneric(\n pagedURL,\n "GET",\n headers,\n nil,\n nil,\n tempEvents,\n )\n if err != nil {\n return 0, 0, 0, fmt.Errorf("failed to get user events (page %d): %v", page, err)\n }\n\n // If no events, we\'ve reached the end\n if len(tempEvents) == 0 {\n break\n }\n\n // Accumulate events\n allEvents = append(allEvents, tempEvents...)\n page++\n }\n\n // The page - 1 is how many pages we actually fetched\n totalPages = page - 1\n\n // Count commits and MRs\n for _, event := range allEvents {\n switch {\n case event.ActionName == "pushed to" || event.ActionName == "pushed new":\n if event.PushData != nil {\n commits += event.PushData.CommitCount\n }\n case event.ActionName == "accepted" && event.TargetType != nil && *event.TargetType == "MergeRequest":\n mrs++\n case event.ActionName == "opened" && event.TargetType != nil && *event.TargetType == "MergeRequest":\n mrs++\n default:\n // TODO: define a \'miscellaneous\' category for other types of events\n commits++\n }\n }\n\n return commits, mrs, totalPages, nil\n}\n\nfunc printResults(stats []ContributionStats) {\n\n // loop over stats to get the average number of commits per merge request\n var totalMRs, totalCommits int\n for _, stat := range stats {\n totalMRs += stat.YearlyMRs\n totalCommits += stat.YearlyCommits\n }\n\n averageCommitsPerMR := int(float64(totalCommits) / float64(totalMRs))\n\n // Find maximum values for normalization\n var maxDaily, maxWeekly, maxMonthly, maxYearly int\n for _, stat := range stats {\n maxDaily = max(maxDaily, stat.DailyCommits+(stat.DailyMRs*averageCommitsPerMR))\n maxWeekly = max(maxWeekly, stat.WeeklyCommits+(stat.WeeklyMRs*averageCommitsPerMR))\n maxMonthly = max(maxMonthly, stat.MonthlyCommits+(stat.MonthlyMRs*averageCommitsPerMR))\n maxYearly = max(maxYearly, stat.YearlyCommits+(stat.YearlyMRs*averageCommitsPerMR))\n }\n\n var maxDailyCommits, maxWeeklyCommits, maxMonthlyCommits, maxYearlyCommits int\n for _, stat := range stats {\n maxDailyCommits = max(maxDailyCommits, stat.DailyCommits)\n maxWeeklyCommits = max(maxWeeklyCommits, stat.WeeklyCommits)\n maxMonthlyCommits = max(maxMonthlyCommits, stat.MonthlyCommits)\n maxYearlyCommits = max(maxYearlyCommits, stat.YearlyCommits)\n }\n\n var maxDailyMRs, maxWeeklyMRs, maxMonthlyMRs, maxYearlyMRs int\n for _, stat := range stats {\n maxDailyMRs = max(maxDailyMRs, stat.DailyMRs)\n maxWeeklyMRs = max(maxWeeklyMRs, stat.WeeklyMRs)\n maxMonthlyMRs = max(maxMonthlyMRs, stat.MonthlyMRs)\n maxYearlyMRs = max(maxYearlyMRs, stat.YearlyMRs)\n }\n\n // sort stats by scaled contributions (commts + scaled MRs)\n sort.Slice(stats, func(i, j int) bool {\n return (stats[i].YearlyCommits + stats[i].YearlyMRs*averageCommitsPerMR) > (stats[j].YearlyCommits + stats[j].YearlyMRs*averageCommitsPerMR)\n })\n\n fmt.Println("\\n\u{1F3C6} Top Contributors Overall\\n")\n fmt.Printf("(MRs scaled by %dx, the global average commits per MR across Solve)\\n", averageCommitsPerMR)\n fmt.Println("========================")\n\n emojisByRank := []string{"\u{1F947}", "\u{1F948}", "\u{1F949}"}\n\n for index, stat := range stats {\n // Use the index to get the corresponding emoji - if we are less than three, use the list\n emoji := "\u{1F468}\u200D\u{1F4BB}"\n if index < len(emojisByRank) {\n emoji = emojisByRank[index]\n }\n fmt.Printf("\\n%s %s\\n", emoji, stat.Username)\n fmt.Printf(" (Fetched %d pages from GitLab)\\n", stat.PagesFetched)\n\n // Print daily, weekly, monthly, yearly bars\n printBar("Today ", stat.DailyCommits+(stat.DailyMRs*averageCommitsPerMR), maxDaily)\n printBar("Week ", stat.WeeklyCommits+(stat.WeeklyMRs*averageCommitsPerMR), maxWeekly)\n printBar("Month ", stat.MonthlyCommits+(stat.MonthlyMRs*averageCommitsPerMR), maxMonthly)\n printBar("Year ", stat.YearlyCommits+(stat.YearlyMRs*averageCommitsPerMR), maxYearly)\n\n fmt.Printf("Details: %d commits, %d MRs in the past year. Total Score: %d\\n",\n stat.YearlyCommits, stat.YearlyMRs, stat.YearlyCommits+(stat.YearlyMRs*averageCommitsPerMR))\n }\n\n // sort stats by commits now\n sort.Slice(stats, func(i, j int) bool {\n return stats[i].YearlyCommits > stats[j].YearlyCommits\n })\n\n fmt.Println("\\n\u{1F3C6} Top Committers")\n fmt.Println("========================")\n\n for index, stat := range stats {\n // Use the index to get the corresponding emoji - if we are less than three, use the list\n emoji := "\u{1F468}\u200D\u{1F4BB}"\n if index < len(emojisByRank) {\n emoji = emojisByRank[index]\n }\n fmt.Printf("\\n%s %s\\n", emoji, stat.Username)\n fmt.Printf(" (Fetched %d pages from GitLab)\\n", stat.PagesFetched)\n\n // Print daily, weekly, monthly, yearly bars\n printBar("Today ", stat.DailyCommits, maxDailyCommits)\n printBar("Week ", stat.WeeklyCommits, maxWeeklyCommits)\n printBar("Month ", stat.MonthlyCommits, maxMonthlyCommits)\n printBar("Year ", stat.YearlyCommits, maxYearlyCommits)\n\n fmt.Printf("Details: %d commits in the past year\\n",\n stat.YearlyCommits)\n }\n\n // sort stats by MRs now\n sort.Slice(stats, func(i, j int) bool {\n return stats[i].YearlyMRs > stats[j].YearlyMRs\n })\n\n fmt.Println("\\n\u{1F3C6} Top Merge Requesters")\n fmt.Println("========================")\n\n for index, stat := range stats {\n // Use the index to get the corresponding emoji - if we are less than three, use the list\n emoji := "\u{1F468}\u200D\u{1F4BB}"\n if index < len(emojisByRank) {\n emoji = emojisByRank[index]\n }\n fmt.Printf("\\n%s %s\\n", emoji, stat.Username)\n fmt.Printf(" (Fetched %d pages from GitLab)\\n", stat.PagesFetched)\n\n // Print daily, weekly, monthly, yearly bars\n printBar("Today ", stat.DailyMRs, maxDailyMRs)\n printBar("Week ", stat.WeeklyMRs, maxWeeklyMRs)\n printBar("Month ", stat.MonthlyMRs, maxMonthlyMRs)\n printBar("Year ", stat.YearlyMRs, maxYearlyMRs)\n\n fmt.Printf("Details: %d MRs in the past year\\n",\n stat.YearlyMRs)\n }\n}\n\nfunc printBar(label string, value, maxValue int) {\n const barWidth = 30\n var percentage float64\n if maxValue > 0 {\n percentage = float64(value) / float64(maxValue) * 100\n }\n\n filledWidth := int(float64(barWidth) * percentage / 100)\n bar := strings.Repeat("\u2588", filledWidth) + strings.Repeat("\u2591", barWidth-filledWidth)\n\n fmt.Printf("%s [%s] %.1f%% (%d)\\n", label, bar, percentage, value)\n}\n\nfunc max(a, b int) int {\n if a > b {\n return a\n }\n return b\n}\n',
|
|
2914
|
+
"language": "Go",
|
|
2915
|
+
"caretPosition": {
|
|
2916
|
+
"row": 0,
|
|
2917
|
+
"col": 0
|
|
2918
|
+
}
|
|
2919
|
+
},
|
|
2920
|
+
"main_test.go": {
|
|
2921
|
+
"type": "file",
|
|
2922
|
+
"content": `package main
|
|
2923
|
+
|
|
2924
|
+
import (
|
|
2925
|
+
"log"
|
|
2926
|
+
"os"
|
|
2927
|
+
"testing"
|
|
2928
|
+
"time"
|
|
2929
|
+
|
|
2930
|
+
"github.com/joho/godotenv"
|
|
2931
|
+
"solve.ch/solvers/http_helper"
|
|
2932
|
+
)
|
|
2933
|
+
|
|
2934
|
+
func TestGetUserActivity(t *testing.T) {
|
|
2935
|
+
// Load .env file
|
|
2936
|
+
err := godotenv.Load()
|
|
2937
|
+
if err != nil {
|
|
2938
|
+
log.Println("Warning: Error loading .env file:", err)
|
|
2939
|
+
}
|
|
2940
|
+
|
|
2941
|
+
// Get GitLab token from environment
|
|
2942
|
+
token := os.Getenv("GITLAB_TOKEN")
|
|
2943
|
+
if token == "" {
|
|
2944
|
+
t.Fatal("GITLAB_TOKEN environment variable not set")
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
// Set up time range for last 7 days
|
|
2948
|
+
now := time.Now()
|
|
2949
|
+
sevenDaysAgo := now.AddDate(0, 0, -7)
|
|
2950
|
+
|
|
2951
|
+
// Test GetUserActivity function
|
|
2952
|
+
commits, mrs, err := GetUserActivity(token, "cfrewin", sevenDaysAgo, now)
|
|
2953
|
+
if err != nil {
|
|
2954
|
+
t.Fatalf("GetUserActivity failed: %v", err)
|
|
2955
|
+
}
|
|
2956
|
+
|
|
2957
|
+
// Log the results
|
|
2958
|
+
t.Logf("Activity for user 'cfrewin' in the last 7 days:")
|
|
2959
|
+
t.Logf("Commits: %d", commits)
|
|
2960
|
+
t.Logf("Merge Requests: %d", mrs)
|
|
2961
|
+
|
|
2962
|
+
// From the sample JSON, we can see several commits and MRs
|
|
2963
|
+
// Let's add some basic assertions
|
|
2964
|
+
if commits == 0 {
|
|
2965
|
+
t.Log("Warning: No commits found in the last 7 days")
|
|
2966
|
+
}
|
|
2967
|
+
if mrs < 0 {
|
|
2968
|
+
t.Error("Number of merge requests should not be negative")
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2971
|
+
// Add detailed logging of commits by looking at the raw response
|
|
2972
|
+
responseData, err := http_helper.MakeHTTPRequest(
|
|
2973
|
+
"https://git.solve.ch/api/v4/users/cfrewin/events",
|
|
2974
|
+
"GET",
|
|
2975
|
+
map[string]string{"PRIVATE-TOKEN": token},
|
|
2976
|
+
nil,
|
|
2977
|
+
nil,
|
|
2978
|
+
)
|
|
2979
|
+
if err != nil {
|
|
2980
|
+
t.Logf("Failed to get raw events: %v", err)
|
|
2981
|
+
} else {
|
|
2982
|
+
t.Logf("Raw response: %s", string(responseData))
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2985
|
+
`,
|
|
2986
|
+
"language": "Go",
|
|
2987
|
+
"caretPosition": {
|
|
2988
|
+
"row": 0,
|
|
2989
|
+
"col": 0
|
|
2990
|
+
}
|
|
2991
|
+
},
|
|
2992
|
+
"usernames.txt": {
|
|
2993
|
+
"type": "file",
|
|
2994
|
+
"content": "cfrewin\nsgacond\nthuber\nsgulas\nvkamberi\nurust\nsreber\nsrupp\nmsobotka\ndavid.ritter\nrlauber\nmkohler\nakati\nmhemmi\njheron",
|
|
2995
|
+
"language": "Plain Text",
|
|
2996
|
+
"caretPosition": {
|
|
2997
|
+
"row": 0,
|
|
2998
|
+
"col": 0
|
|
2999
|
+
}
|
|
833
3000
|
}
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
"name": "file-explorer-type-new-file-input",
|
|
865
|
-
"value": "main.py"
|
|
866
|
-
},
|
|
867
|
-
{
|
|
868
|
-
"name": "file-explorer-enter-new-file-input",
|
|
869
|
-
"value": "1"
|
|
870
|
-
},
|
|
871
|
-
{
|
|
872
|
-
"name": "author-speak-before",
|
|
873
|
-
"value": "and let's print 'Hello world!' to the console."
|
|
874
|
-
},
|
|
875
|
-
{
|
|
876
|
-
"name": "editor-type",
|
|
877
|
-
"value": "print('Hello, world!')"
|
|
878
|
-
},
|
|
879
|
-
{
|
|
880
|
-
"name": "author-speak-before",
|
|
881
|
-
"value": "and if I wanted to write the value of some variable to the console, I could do that like so:"
|
|
882
|
-
},
|
|
883
|
-
{
|
|
884
|
-
"name": "editor-backspace",
|
|
885
|
-
"value": "22"
|
|
886
|
-
},
|
|
887
|
-
{
|
|
888
|
-
"name": "editor-type",
|
|
889
|
-
"value": "my_variable = 5"
|
|
890
|
-
},
|
|
891
|
-
{
|
|
892
|
-
"name": "editor-enter",
|
|
893
|
-
"value": "1"
|
|
894
|
-
},
|
|
895
|
-
{
|
|
896
|
-
"name": "editor-type",
|
|
897
|
-
"value": "print(my_variable)"
|
|
898
|
-
},
|
|
899
|
-
{
|
|
900
|
-
"name": "author-speak-before",
|
|
901
|
-
"value": "Now, when I run this code, I would expect the value of 'my_variable' to be printed to the console. Something like:"
|
|
902
|
-
},
|
|
903
|
-
{
|
|
904
|
-
"name": "editor-enter",
|
|
905
|
-
"value": "1"
|
|
906
|
-
},
|
|
907
|
-
{
|
|
908
|
-
"name": "editor-type",
|
|
909
|
-
"value": "# 5"
|
|
910
|
-
},
|
|
911
|
-
{
|
|
912
|
-
"name": "author-speak-before",
|
|
913
|
-
"value": "Console logging is simple, yet powerful and very useful!"
|
|
914
|
-
},
|
|
915
|
-
{
|
|
916
|
-
"name": "author-speak-before",
|
|
917
|
-
"value": "Well done, lets save our file."
|
|
918
|
-
},
|
|
919
|
-
{
|
|
920
|
-
"name": "editor-save",
|
|
921
|
-
"value": "main.py"
|
|
922
|
-
},
|
|
923
|
-
{
|
|
924
|
-
"name": "author-speak-before",
|
|
925
|
-
"value": "Saved!"
|
|
926
|
-
},
|
|
927
|
-
{
|
|
928
|
-
"name": "author-speak-before",
|
|
929
|
-
"value": "Oh... and I've just realized... this file looks a bit strange... let's add the python shebang to the top of the file."
|
|
930
|
-
},
|
|
931
|
-
{
|
|
932
|
-
"name": "editor-arrow-up",
|
|
933
|
-
"value": "4"
|
|
934
|
-
},
|
|
935
|
-
{
|
|
936
|
-
"name": "editor-command-left",
|
|
937
|
-
"value": "1"
|
|
938
|
-
},
|
|
939
|
-
{
|
|
940
|
-
"name": "editor-enter",
|
|
941
|
-
"value": "1"
|
|
942
|
-
},
|
|
943
|
-
{
|
|
944
|
-
"name": "editor-arrow-up",
|
|
945
|
-
"value": "1"
|
|
946
|
-
},
|
|
947
|
-
{
|
|
948
|
-
"name": "editor-type",
|
|
949
|
-
"value": "#!/usr/bin/env python"
|
|
950
|
-
},
|
|
951
|
-
{
|
|
952
|
-
"name": "editor-enter",
|
|
953
|
-
"value": "1"
|
|
954
|
-
},
|
|
955
|
-
{
|
|
956
|
-
"name": "author-speak-before",
|
|
957
|
-
"value": "Ah...much better!"
|
|
958
|
-
},
|
|
959
|
-
{
|
|
960
|
-
"name": "author-speak-before",
|
|
961
|
-
"value": "Let's save the file again."
|
|
962
|
-
},
|
|
963
|
-
{
|
|
964
|
-
"name": "editor-save",
|
|
965
|
-
"value": "main.py"
|
|
966
|
-
},
|
|
967
|
-
{
|
|
968
|
-
"name": "author-speak-before",
|
|
969
|
-
"value": "Saved!"
|
|
970
|
-
},
|
|
971
|
-
{
|
|
972
|
-
"name": "author-speak-before",
|
|
973
|
-
"value": "Now, let's open a terminal and run this file..."
|
|
974
|
-
},
|
|
975
|
-
{
|
|
976
|
-
"name": "terminal-open",
|
|
977
|
-
"value": "1"
|
|
978
|
-
},
|
|
979
|
-
{
|
|
980
|
-
"name": "terminal-type",
|
|
981
|
-
"value": "python main.py"
|
|
982
|
-
},
|
|
983
|
-
{
|
|
984
|
-
"name": "terminal-set-output",
|
|
985
|
-
"value": "5"
|
|
986
|
-
},
|
|
987
|
-
{
|
|
988
|
-
"name": "terminal-enter",
|
|
989
|
-
"value": "1"
|
|
990
|
-
},
|
|
3001
|
+
}
|
|
3002
|
+
},
|
|
3003
|
+
"editorSnapshot": {
|
|
3004
|
+
"isEditorContextMenuOpen": false,
|
|
3005
|
+
"editors": []
|
|
3006
|
+
},
|
|
3007
|
+
"terminalSnapshot": {
|
|
3008
|
+
"terminals": []
|
|
3009
|
+
},
|
|
3010
|
+
"mouseSnapshot": {
|
|
3011
|
+
"location": "editor",
|
|
3012
|
+
"currentHoveredFileName": "",
|
|
3013
|
+
"currentHoveredFolderName": "",
|
|
3014
|
+
"currentHoveredEditorTabFileName": "",
|
|
3015
|
+
"x": 0,
|
|
3016
|
+
"y": 0,
|
|
3017
|
+
"timestamp": 0,
|
|
3018
|
+
"type": "move",
|
|
3019
|
+
"buttonStates": {
|
|
3020
|
+
"left": false,
|
|
3021
|
+
"right": false,
|
|
3022
|
+
"middle": false
|
|
3023
|
+
},
|
|
3024
|
+
"scrollPosition": {
|
|
3025
|
+
"x": 0,
|
|
3026
|
+
"y": 0
|
|
3027
|
+
}
|
|
3028
|
+
},
|
|
3029
|
+
"authorSnapshot": {
|
|
3030
|
+
"authors": [
|
|
991
3031
|
{
|
|
992
|
-
|
|
993
|
-
"value": "Perfect, our code is working as expected! I hope you enjoyed this lesson!"
|
|
3032
|
+
"currentSpeechCaption": ""
|
|
994
3033
|
}
|
|
995
|
-
|
|
996
|
-
},
|
|
997
|
-
];
|
|
998
|
-
// returns a JSON representation of an example lesson based on keywords
|
|
999
|
-
// the LLM can use this to learn real example lessons created by humans
|
|
1000
|
-
const getExampleLesson = (keyword) => {
|
|
1001
|
-
const exampleJSONs = [];
|
|
1002
|
-
if (Array.isArray(keyword)) {
|
|
1003
|
-
// if the keyword is an array, loop over all keywords and get the example lesson for each one
|
|
1004
|
-
for (var i = 0; i < keyword.length; i++) {
|
|
1005
|
-
const exampleJSON = getExampleLesson(keyword[i]);
|
|
1006
|
-
exampleJSONs.push(exampleJSON);
|
|
3034
|
+
]
|
|
1007
3035
|
}
|
|
1008
|
-
|
|
3036
|
+
}
|
|
1009
3037
|
}
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
3038
|
+
}
|
|
3039
|
+
];
|
|
3040
|
+
var getExampleLessonByKeyword = (keyword) => {
|
|
3041
|
+
const exampleJSONs = [];
|
|
3042
|
+
if (Array.isArray(keyword)) {
|
|
3043
|
+
for (var i = 0; i < keyword.length; i++) {
|
|
3044
|
+
const exampleJSON = getExampleLessonByKeyword(keyword[i]);
|
|
3045
|
+
exampleJSONs.push(exampleJSON);
|
|
1016
3046
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
3047
|
+
return exampleJSONs.join(", ");
|
|
3048
|
+
} else {
|
|
3049
|
+
const exampleJSON = getSingleExampleLesson(keyword);
|
|
3050
|
+
if (exampleJSON) {
|
|
3051
|
+
return exampleJSON;
|
|
3052
|
+
}
|
|
3053
|
+
}
|
|
3054
|
+
return "";
|
|
3055
|
+
};
|
|
3056
|
+
var getSingleExampleLesson = (keyword) => {
|
|
3057
|
+
for (var i = 0; i < keywordsToExampleLessonsMap.length; i++) {
|
|
3058
|
+
const keywordMap = keywordsToExampleLessonsMap[i];
|
|
3059
|
+
if (keywordMap.keywords.includes(keyword)) {
|
|
3060
|
+
return JSON.stringify(keywordMap.lesson, null, 2);
|
|
1027
3061
|
}
|
|
1028
|
-
|
|
1029
|
-
|
|
3062
|
+
}
|
|
3063
|
+
return "";
|
|
1030
3064
|
};
|
|
1031
3065
|
|
|
1032
|
-
//
|
|
3066
|
+
// src/index.ts
|
|
1033
3067
|
dotenv.config();
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
3068
|
+
var ActionSchema = z2.object({
|
|
3069
|
+
name: z2.string(),
|
|
3070
|
+
value: z2.string()
|
|
1037
3071
|
});
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
3072
|
+
var validActionNames = new Set(AllActionStrings);
|
|
3073
|
+
var StrictActionSchema = z2.object({
|
|
3074
|
+
name: z2.string().refine((name) => validActionNames.has(name), "unknown CodeVideo action name"),
|
|
3075
|
+
value: z2.string().min(1, "action 'value' must not be empty")
|
|
3076
|
+
}).refine(
|
|
3077
|
+
(a) => !isRepeatableAction2(a) || /^[0-9]+$/.test(a.value),
|
|
3078
|
+
{ message: 'repeatable actions require a positive integer string value (usually "1")' }
|
|
3079
|
+
);
|
|
3080
|
+
var CourseSnapshotSchema = z2.custom((value) => {
|
|
3081
|
+
if (!value || typeof value !== "object") return false;
|
|
3082
|
+
const snapshot = value;
|
|
3083
|
+
return typeof snapshot.isUnsavedChangesDialogOpen === "boolean" && typeof snapshot.unsavedFileName === "string" && !!snapshot.fileExplorerSnapshot && !!snapshot.editorSnapshot && !!snapshot.terminalSnapshot && !!snapshot.mouseSnapshot && !!snapshot.authorSnapshot;
|
|
3084
|
+
}, "Invalid CodeVideo course snapshot");
|
|
3085
|
+
var LessonSchema = z2.object({
|
|
3086
|
+
id: z2.string(),
|
|
3087
|
+
name: z2.string(),
|
|
3088
|
+
description: z2.string(),
|
|
3089
|
+
actions: z2.array(ActionSchema),
|
|
3090
|
+
initialSnapshot: CourseSnapshotSchema,
|
|
3091
|
+
finalSnapshot: CourseSnapshotSchema
|
|
3092
|
+
});
|
|
3093
|
+
var CourseSchema = z2.object({
|
|
3094
|
+
id: z2.string(),
|
|
3095
|
+
name: z2.string(),
|
|
3096
|
+
description: z2.string(),
|
|
3097
|
+
primaryLanguage: z2.string(),
|
|
3098
|
+
lessons: z2.array(LessonSchema)
|
|
1046
3099
|
});
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
3100
|
+
var RenderOptionsParam = RenderOptionsSchema.optional().describe("Render options: orientation, IDE layout (theme, font size, which panes and the cursor are visible), pacing, voice and render mode.");
|
|
3101
|
+
var openParam = (whenOmitted) => z2.boolean().optional().describe(`Open the finished video in the default player. Defaults to ${whenOmitted}.`);
|
|
3102
|
+
var QUEUED_MESSAGE = "Queued. It renders in the background, even if this client closes; check it with codevideo_get_render_job.";
|
|
3103
|
+
var jobReply = (result) => ({
|
|
3104
|
+
content: [{
|
|
3105
|
+
type: "text",
|
|
3106
|
+
text: JSON.stringify({ ...describeJob(result.job), estimatedSeconds: result.estimatedSeconds, message: QUEUED_MESSAGE }, null, 2)
|
|
3107
|
+
}]
|
|
1053
3108
|
});
|
|
1054
|
-
|
|
3109
|
+
var errorReply = (prefix, error) => ({
|
|
3110
|
+
isError: true,
|
|
3111
|
+
content: [{ type: "text", text: `${prefix}: ${error instanceof Error ? error.message : String(error)}` }]
|
|
3112
|
+
});
|
|
3113
|
+
function createMcpServer() {
|
|
3114
|
+
const server = new McpServer({
|
|
1055
3115
|
name: "codevideo-mcp",
|
|
1056
|
-
version
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
3116
|
+
version
|
|
3117
|
+
});
|
|
3118
|
+
setupMcpServerTools(server);
|
|
3119
|
+
return server;
|
|
3120
|
+
}
|
|
3121
|
+
function setupMcpServerTools(server) {
|
|
3122
|
+
const tool = server.tool.bind(server);
|
|
3123
|
+
tool(
|
|
3124
|
+
"codevideo_instructions_to_create_a_course",
|
|
3125
|
+
{
|
|
3126
|
+
additionalContext: z2.string()
|
|
3127
|
+
},
|
|
3128
|
+
async ({ additionalContext }) => {
|
|
3129
|
+
return {
|
|
3130
|
+
content: [{
|
|
3131
|
+
type: "text",
|
|
3132
|
+
text: instructionsToCreateACourse(additionalContext)
|
|
3133
|
+
}]
|
|
3134
|
+
};
|
|
1063
3135
|
}
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
content: {
|
|
1076
|
-
type: "text",
|
|
1077
|
-
text: createLesson(additionalContext)
|
|
1078
|
-
}
|
|
3136
|
+
);
|
|
3137
|
+
tool(
|
|
3138
|
+
"codevideo_instructions_to_create_lesson_actions",
|
|
3139
|
+
{
|
|
3140
|
+
additionalContext: z2.string()
|
|
3141
|
+
},
|
|
3142
|
+
async ({ additionalContext }) => {
|
|
3143
|
+
return {
|
|
3144
|
+
content: [{
|
|
3145
|
+
type: "text",
|
|
3146
|
+
text: instructionsToCreateLessonActions(additionalContext)
|
|
1079
3147
|
}]
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
3148
|
+
};
|
|
3149
|
+
}
|
|
3150
|
+
);
|
|
3151
|
+
tool(
|
|
3152
|
+
"codevideo_instructions_to_create_lesson_video",
|
|
3153
|
+
{
|
|
3154
|
+
additionalContext: z2.string()
|
|
3155
|
+
},
|
|
3156
|
+
async ({ additionalContext }) => {
|
|
3157
|
+
return {
|
|
3158
|
+
content: [{
|
|
3159
|
+
type: "text",
|
|
3160
|
+
text: instructionsToCreateLessonVideo(additionalContext)
|
|
1091
3161
|
}]
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
"
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
3162
|
+
};
|
|
3163
|
+
}
|
|
3164
|
+
);
|
|
3165
|
+
tool(
|
|
3166
|
+
"codevideo_instructions_to_translate_actions",
|
|
3167
|
+
"Generate translation instructions for CodeVideo actions. Pass actions array OR use previously stored actions from codevideo_set_current_actions (recommended workflow).",
|
|
3168
|
+
{
|
|
3169
|
+
actionsToTranslation: z2.array(ActionSchema).optional().describe("Array of CodeVideo actions to translate. Optional if actions were previously stored with codevideo_set_current_actions"),
|
|
3170
|
+
targetLanguage: z2.string().describe("Target language for translation (e.g., 'Spanish', 'French', 'German')")
|
|
3171
|
+
},
|
|
3172
|
+
async ({ actionsToTranslation, targetLanguage }) => {
|
|
3173
|
+
const typedActions = actionsToTranslation;
|
|
3174
|
+
return {
|
|
3175
|
+
content: [{
|
|
3176
|
+
type: "text",
|
|
3177
|
+
text: instructionsToTranslateActions(typedActions, targetLanguage)
|
|
1102
3178
|
}]
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
},
|
|
1109
|
-
|
|
3179
|
+
};
|
|
3180
|
+
}
|
|
3181
|
+
);
|
|
3182
|
+
tool(
|
|
3183
|
+
"codevideo_get_empty_lesson_snapshot",
|
|
3184
|
+
{},
|
|
3185
|
+
async () => {
|
|
3186
|
+
return {
|
|
1110
3187
|
content: [{
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
}
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
3188
|
+
type: "text",
|
|
3189
|
+
text: getEmptyLessonSnapshot()
|
|
3190
|
+
}]
|
|
3191
|
+
};
|
|
3192
|
+
}
|
|
3193
|
+
);
|
|
3194
|
+
tool(
|
|
3195
|
+
"codevideo_create_course_with_initial_metadata",
|
|
3196
|
+
{
|
|
3197
|
+
id: z2.string(),
|
|
3198
|
+
name: z2.string(),
|
|
3199
|
+
description: z2.string(),
|
|
3200
|
+
primaryLanguage: z2.string()
|
|
3201
|
+
},
|
|
3202
|
+
async ({ id, name, description, primaryLanguage }) => {
|
|
3203
|
+
return {
|
|
1120
3204
|
content: [{
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
3205
|
+
type: "text",
|
|
3206
|
+
text: createCourseWithInitialMetadata(id, name, description, primaryLanguage)
|
|
3207
|
+
}]
|
|
3208
|
+
};
|
|
3209
|
+
}
|
|
3210
|
+
);
|
|
3211
|
+
tool(
|
|
3212
|
+
"codevideo_add_actions_to_lesson",
|
|
3213
|
+
{
|
|
3214
|
+
lesson: LessonSchema,
|
|
3215
|
+
actions: z2.array(ActionSchema)
|
|
3216
|
+
},
|
|
3217
|
+
async ({ lesson, actions }) => {
|
|
3218
|
+
const typedLesson = lesson;
|
|
3219
|
+
const typedActions = actions;
|
|
3220
|
+
return {
|
|
1130
3221
|
content: [{
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
|
|
3222
|
+
type: "text",
|
|
3223
|
+
text: addActionsToLesson(typedLesson, typedActions)
|
|
3224
|
+
}]
|
|
3225
|
+
};
|
|
3226
|
+
}
|
|
3227
|
+
);
|
|
3228
|
+
tool(
|
|
3229
|
+
"codevideo_get_final_lesson_snapshot",
|
|
3230
|
+
"Get the final state snapshot after applying CodeVideo actions. Pass actions array OR use previously stored actions from codevideo_set_current_actions (recommended workflow).",
|
|
3231
|
+
{
|
|
3232
|
+
actions: z2.array(ActionSchema).optional().describe("Array of CodeVideo actions to apply. Optional if actions were previously stored with codevideo_set_current_actions")
|
|
3233
|
+
},
|
|
3234
|
+
async ({ actions }) => {
|
|
3235
|
+
const typedActions = actions;
|
|
3236
|
+
return {
|
|
1138
3237
|
content: [{
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
}
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
3238
|
+
type: "text",
|
|
3239
|
+
text: getFinalLessonSnapshot(typedActions)
|
|
3240
|
+
}]
|
|
3241
|
+
};
|
|
3242
|
+
}
|
|
3243
|
+
);
|
|
3244
|
+
tool(
|
|
3245
|
+
"codevideo_add_lesson_to_course",
|
|
3246
|
+
{
|
|
3247
|
+
course: CourseSchema,
|
|
3248
|
+
id: z2.string(),
|
|
3249
|
+
name: z2.string(),
|
|
3250
|
+
description: z2.string()
|
|
3251
|
+
},
|
|
3252
|
+
async ({ course, id, name, description }) => {
|
|
3253
|
+
const typedCourse = course;
|
|
3254
|
+
return {
|
|
1151
3255
|
content: [{
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
}
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
const typedActions = actions;
|
|
1165
|
-
return {
|
|
3256
|
+
type: "text",
|
|
3257
|
+
text: addLessonToCourse(typedCourse, id, name, description)
|
|
3258
|
+
}]
|
|
3259
|
+
};
|
|
3260
|
+
}
|
|
3261
|
+
);
|
|
3262
|
+
tool(
|
|
3263
|
+
"codevideo_get_action_names",
|
|
3264
|
+
"Every CodeVideo action, one per line: what it does and what its value takes. Check the rows for the actions you plan to use.",
|
|
3265
|
+
{},
|
|
3266
|
+
async () => {
|
|
3267
|
+
return {
|
|
1166
3268
|
content: [{
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
3269
|
+
type: "text",
|
|
3270
|
+
text: getActionNames()
|
|
3271
|
+
}]
|
|
3272
|
+
};
|
|
3273
|
+
}
|
|
3274
|
+
);
|
|
3275
|
+
tool(
|
|
3276
|
+
"codevideo_get_example_actions_array_by_keyword",
|
|
3277
|
+
{
|
|
3278
|
+
keyword: z2.string()
|
|
3279
|
+
},
|
|
3280
|
+
async ({ keyword }) => {
|
|
3281
|
+
return {
|
|
1178
3282
|
content: [{
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
},
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
return {
|
|
3283
|
+
type: "text",
|
|
3284
|
+
text: getExampleActionsArrayByKeyword(keyword)
|
|
3285
|
+
}]
|
|
3286
|
+
};
|
|
3287
|
+
}
|
|
3288
|
+
);
|
|
3289
|
+
tool(
|
|
3290
|
+
"codevideo_get_example_lesson_by_keyword",
|
|
3291
|
+
{
|
|
3292
|
+
keyword: z2.string()
|
|
3293
|
+
},
|
|
3294
|
+
async ({ keyword }) => {
|
|
3295
|
+
return {
|
|
1193
3296
|
content: [{
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
|
-
|
|
3297
|
+
type: "text",
|
|
3298
|
+
text: getExampleLessonByKeyword(keyword)
|
|
3299
|
+
}]
|
|
3300
|
+
};
|
|
3301
|
+
}
|
|
3302
|
+
);
|
|
3303
|
+
tool(
|
|
3304
|
+
"codevideo_get_initial_lesson_state_from_local_repo",
|
|
3305
|
+
"Create an initial lesson state snapshot from a local repository/codebase directory. This captures the file structure and creates an empty lesson template ready for action recording.",
|
|
3306
|
+
{
|
|
3307
|
+
repoLocation: z2.string().describe("Local file system path to the repository/codebase directory")
|
|
3308
|
+
},
|
|
3309
|
+
async ({ repoLocation }) => {
|
|
3310
|
+
return {
|
|
1201
3311
|
content: [{
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
3312
|
+
type: "text",
|
|
3313
|
+
text: await getInitialLessonStateFromLocalCodebase(repoLocation)
|
|
3314
|
+
}]
|
|
3315
|
+
};
|
|
3316
|
+
}
|
|
3317
|
+
);
|
|
3318
|
+
tool(
|
|
3319
|
+
"codevideo_get_initial_lesson_state_from_github_repo",
|
|
3320
|
+
"Create an initial lesson state snapshot from a GitHub repository URL. This fetches the repository content, captures the file structure, and creates an empty lesson template ready for action recording.",
|
|
3321
|
+
{
|
|
3322
|
+
repoUrl: z2.string().describe("GitHub repository URL (e.g., https://github.com/user/repo)")
|
|
3323
|
+
},
|
|
3324
|
+
async ({ repoUrl }) => {
|
|
3325
|
+
return {
|
|
1211
3326
|
content: [{
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
}
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
3327
|
+
type: "text",
|
|
3328
|
+
text: await getInitialLessonStateFromGitHubRepo(repoUrl)
|
|
3329
|
+
}]
|
|
3330
|
+
};
|
|
3331
|
+
}
|
|
3332
|
+
);
|
|
3333
|
+
tool(
|
|
3334
|
+
"codevideo_make_markdown_from_actions",
|
|
3335
|
+
"Generate a markdown blog post from CodeVideo actions array. Pass actions array OR use previously stored actions from codevideo_set_current_actions (recommended workflow).",
|
|
3336
|
+
{
|
|
3337
|
+
actions: z2.array(ActionSchema).optional().describe("Array of CodeVideo actions. Optional if actions were previously stored with codevideo_set_current_actions")
|
|
3338
|
+
},
|
|
3339
|
+
async ({ actions }) => {
|
|
3340
|
+
const typedActions = actions;
|
|
3341
|
+
return {
|
|
1221
3342
|
content: [{
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
3343
|
+
type: "text",
|
|
3344
|
+
text: makeMarkdownFromActions(typedActions)
|
|
3345
|
+
}]
|
|
3346
|
+
};
|
|
3347
|
+
}
|
|
3348
|
+
);
|
|
3349
|
+
tool(
|
|
3350
|
+
"codevideo_make_markdown_from_lesson",
|
|
3351
|
+
"Generate a markdown blog post from a complete lesson object. Pass a lesson object OR use previously stored lesson from lesson management tools. This extracts the actions from the lesson and generates markdown.",
|
|
3352
|
+
{
|
|
3353
|
+
lesson: LessonSchema.optional().describe("Complete lesson object. Optional if lesson was previously stored with lesson management tools")
|
|
3354
|
+
},
|
|
3355
|
+
async ({ lesson }) => {
|
|
3356
|
+
const typedLesson = lesson;
|
|
3357
|
+
return {
|
|
1233
3358
|
content: [{
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
}
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
3359
|
+
type: "text",
|
|
3360
|
+
text: makeMarkdownFromLesson(typedLesson)
|
|
3361
|
+
}]
|
|
3362
|
+
};
|
|
3363
|
+
}
|
|
3364
|
+
);
|
|
3365
|
+
tool(
|
|
3366
|
+
"codevideo_make_html_web_page",
|
|
3367
|
+
"Generate an HTML web page from CodeVideo actions. Pass actions array OR use previously stored actions from codevideo_set_current_actions (recommended workflow).",
|
|
3368
|
+
{
|
|
3369
|
+
actions: z2.array(ActionSchema).optional().describe("Array of CodeVideo actions. Optional if actions were previously stored with codevideo_set_current_actions")
|
|
3370
|
+
},
|
|
3371
|
+
async ({ actions }) => {
|
|
3372
|
+
const typedActions = actions;
|
|
3373
|
+
return {
|
|
1245
3374
|
content: [{
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
});
|
|
1251
|
-
server.tool("codevideo_make_video", "Generate a video from CodeVideo actions. Pass actions array OR use previously stored actions from codevideo_set_current_actions (recommended workflow).", {
|
|
1252
|
-
actions: z.array(ActionSchema).optional().describe("Array of CodeVideo actions. Optional if actions were previously stored with codevideo_set_current_actions")
|
|
1253
|
-
}, async ({ actions }) => {
|
|
1254
|
-
// Cast the actions to IAction if provided
|
|
1255
|
-
const typedActions = actions;
|
|
1256
|
-
try {
|
|
1257
|
-
const result = await makeVideo(typedActions);
|
|
1258
|
-
return {
|
|
1259
|
-
content: [{
|
|
1260
|
-
type: "text",
|
|
1261
|
-
text: JSON.stringify({
|
|
1262
|
-
outputPath: result.outputPath,
|
|
1263
|
-
stdout: result.stdout,
|
|
1264
|
-
stderr: result.stderr
|
|
1265
|
-
}, null, 2)
|
|
1266
|
-
}]
|
|
1267
|
-
};
|
|
3375
|
+
type: "text",
|
|
3376
|
+
text: await makeHTMLWebPage(typedActions)
|
|
3377
|
+
}]
|
|
3378
|
+
};
|
|
1268
3379
|
}
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
text: `Error creating video: ${error?.message || String(error)}`
|
|
1285
|
-
}]
|
|
1286
|
-
};
|
|
3380
|
+
);
|
|
3381
|
+
tool(
|
|
3382
|
+
"codevideo_make_video_from_actions",
|
|
3383
|
+
"Render a video from CodeVideo actions: pass actions, or omit them to use the ones stored with codevideo_set_current_actions. Queues a background render job and returns its jobId at once; follow it with codevideo_get_render_job.",
|
|
3384
|
+
{
|
|
3385
|
+
actions: z2.array(ActionSchema).optional().describe("Array of CodeVideo actions. Optional if actions were previously stored with codevideo_set_current_actions"),
|
|
3386
|
+
options: RenderOptionsParam,
|
|
3387
|
+
open: openParam(true)
|
|
3388
|
+
},
|
|
3389
|
+
async ({ actions, options, open }) => {
|
|
3390
|
+
try {
|
|
3391
|
+
return jobReply(queueVideoFromActions(actions, options ?? {}, open ?? true));
|
|
3392
|
+
} catch (error) {
|
|
3393
|
+
return errorReply("Couldn't queue the video", error);
|
|
3394
|
+
}
|
|
1287
3395
|
}
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
3396
|
+
);
|
|
3397
|
+
tool(
|
|
3398
|
+
"codevideo_make_video_from_lesson",
|
|
3399
|
+
"Render a video from a complete lesson object: pass the lesson, or omit it to use the one stored with codevideo_set_current_lesson. Queues a background render job and returns its jobId at once; follow it with codevideo_get_render_job. For lessons in a stored course, use codevideo_render_lesson_video instead.",
|
|
3400
|
+
{
|
|
3401
|
+
lesson: LessonSchema.optional().describe("Complete lesson object with snapshots. Optional if lesson was previously stored with lesson management tools"),
|
|
3402
|
+
options: RenderOptionsParam,
|
|
3403
|
+
open: openParam(true)
|
|
3404
|
+
},
|
|
3405
|
+
async ({ lesson, options, open }) => {
|
|
3406
|
+
try {
|
|
3407
|
+
return jobReply(queueVideoFromLesson(lesson, options ?? {}, open ?? true));
|
|
3408
|
+
} catch (error) {
|
|
3409
|
+
return errorReply("Couldn't queue the video", error);
|
|
3410
|
+
}
|
|
3411
|
+
}
|
|
3412
|
+
);
|
|
3413
|
+
tool(
|
|
3414
|
+
"codevideo_validate_actions",
|
|
3415
|
+
"Checks a list of actions: unknown names, values of the wrong kind, and actions lessons can't use, each with what to do instead.",
|
|
3416
|
+
{
|
|
3417
|
+
actions: z2.array(ActionSchema).optional()
|
|
3418
|
+
},
|
|
3419
|
+
async ({ actions }) => {
|
|
3420
|
+
const typedActions = actions;
|
|
3421
|
+
return {
|
|
1295
3422
|
content: [{
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
}
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
3423
|
+
type: "text",
|
|
3424
|
+
text: validateActions(typedActions)
|
|
3425
|
+
}]
|
|
3426
|
+
};
|
|
3427
|
+
}
|
|
3428
|
+
);
|
|
3429
|
+
tool(
|
|
3430
|
+
"codevideo_is_repeatable_action",
|
|
3431
|
+
{
|
|
3432
|
+
action: ActionSchema
|
|
3433
|
+
},
|
|
3434
|
+
async ({ action }) => {
|
|
3435
|
+
const typedAction = action;
|
|
3436
|
+
return {
|
|
1307
3437
|
content: [{
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
}
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
3438
|
+
type: "text",
|
|
3439
|
+
text: isRepeatable(typedAction)
|
|
3440
|
+
}]
|
|
3441
|
+
};
|
|
3442
|
+
}
|
|
3443
|
+
);
|
|
3444
|
+
tool(
|
|
3445
|
+
"codevideo_critique_video_using_gemini",
|
|
3446
|
+
"Critique a generated video using Google Gemini AI. Pass actions array OR use previously stored actions from codevideo_set_current_actions (recommended workflow).",
|
|
3447
|
+
{
|
|
3448
|
+
videoPath: z2.string().describe("Path to the video file to critique"),
|
|
3449
|
+
actions: z2.array(ActionSchema).optional().describe("Array of CodeVideo actions used to generate the video. Optional if actions were previously stored with codevideo_set_current_actions")
|
|
3450
|
+
},
|
|
3451
|
+
async ({ videoPath, actions }) => {
|
|
3452
|
+
const typedActions = actions;
|
|
3453
|
+
try {
|
|
1320
3454
|
const critique = await critiqueVideoUsingGemini(videoPath, typedActions);
|
|
1321
3455
|
return {
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
3456
|
+
content: [{
|
|
3457
|
+
type: "text",
|
|
3458
|
+
text: critique
|
|
3459
|
+
}]
|
|
1326
3460
|
};
|
|
1327
|
-
|
|
1328
|
-
catch (error) {
|
|
3461
|
+
} catch (error) {
|
|
1329
3462
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1330
3463
|
console.error("Error critiquing video:", errorMessage);
|
|
1331
3464
|
return {
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
3465
|
+
content: [{
|
|
3466
|
+
type: "text",
|
|
3467
|
+
text: `Error critiquing video: ${errorMessage}`
|
|
3468
|
+
}]
|
|
1336
3469
|
};
|
|
3470
|
+
}
|
|
1337
3471
|
}
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
3472
|
+
);
|
|
3473
|
+
tool(
|
|
3474
|
+
"codevideo_set_current_actions",
|
|
3475
|
+
"Legacy single-slot store: keep one actions array for later tool calls, so they can run without re-sending it. Every conversation shares this one slot. For courses or several lessons, use codevideo_create_course and the course tools, which keep work apart by id.",
|
|
3476
|
+
{
|
|
3477
|
+
actions: z2.array(ActionSchema).describe("Array of CodeVideo actions to store for later use")
|
|
3478
|
+
},
|
|
3479
|
+
async ({ actions }) => {
|
|
3480
|
+
const typedActions = actions;
|
|
3481
|
+
return {
|
|
1345
3482
|
content: [{
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
3483
|
+
type: "text",
|
|
3484
|
+
text: setCurrentActions(typedActions)
|
|
3485
|
+
}]
|
|
3486
|
+
};
|
|
3487
|
+
}
|
|
3488
|
+
);
|
|
3489
|
+
tool(
|
|
3490
|
+
"codevideo_get_current_actions",
|
|
3491
|
+
"Legacy single-slot store: read back the actions stored with codevideo_set_current_actions (shared by every conversation).",
|
|
3492
|
+
{},
|
|
3493
|
+
async () => {
|
|
3494
|
+
const actions = getCurrentActions();
|
|
3495
|
+
if (!actions) {
|
|
1354
3496
|
return {
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
3497
|
+
content: [{
|
|
3498
|
+
type: "text",
|
|
3499
|
+
text: "No actions found in storage. Use codevideo_set_current_actions first to store actions."
|
|
3500
|
+
}]
|
|
1359
3501
|
};
|
|
3502
|
+
}
|
|
3503
|
+
return {
|
|
3504
|
+
content: [{
|
|
3505
|
+
type: "text",
|
|
3506
|
+
text: JSON.stringify(actions, null, 2)
|
|
3507
|
+
}]
|
|
3508
|
+
};
|
|
1360
3509
|
}
|
|
1361
|
-
|
|
3510
|
+
);
|
|
3511
|
+
tool(
|
|
3512
|
+
"codevideo_set_current_lesson",
|
|
3513
|
+
"Legacy single-slot store: keep one lesson object for later tool calls, so they can run without re-sending it. Every conversation shares this one slot. For courses or several lessons, use codevideo_create_course and the course tools, which keep work apart by id.",
|
|
3514
|
+
{
|
|
3515
|
+
lesson: LessonSchema.describe("Complete lesson object to store for later use")
|
|
3516
|
+
},
|
|
3517
|
+
async ({ lesson }) => {
|
|
3518
|
+
const typedLesson = lesson;
|
|
3519
|
+
return {
|
|
1362
3520
|
content: [{
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
}
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
3521
|
+
type: "text",
|
|
3522
|
+
text: setCurrentLesson(typedLesson)
|
|
3523
|
+
}]
|
|
3524
|
+
};
|
|
3525
|
+
}
|
|
3526
|
+
);
|
|
3527
|
+
tool(
|
|
3528
|
+
"codevideo_get_current_lesson",
|
|
3529
|
+
"Legacy single-slot store: read back the lesson stored with codevideo_set_current_lesson (shared by every conversation).",
|
|
3530
|
+
{},
|
|
3531
|
+
async () => {
|
|
3532
|
+
const lesson = getCurrentLesson();
|
|
3533
|
+
if (!lesson) {
|
|
3534
|
+
return {
|
|
3535
|
+
content: [{
|
|
3536
|
+
type: "text",
|
|
3537
|
+
text: "No lesson found in storage. Use codevideo_set_current_lesson first to store a lesson."
|
|
3538
|
+
}]
|
|
3539
|
+
};
|
|
3540
|
+
}
|
|
3541
|
+
return {
|
|
3542
|
+
content: [{
|
|
3543
|
+
type: "text",
|
|
3544
|
+
text: JSON.stringify(lesson, null, 2)
|
|
3545
|
+
}]
|
|
3546
|
+
};
|
|
3547
|
+
}
|
|
3548
|
+
);
|
|
3549
|
+
tool(
|
|
3550
|
+
"codevideo_instructions_to_build_course_from_scratch",
|
|
3551
|
+
"Get step-by-step instructions for building a COMPLETE multi-lesson 'build from scratch' course with the course-store tools (snapshots stay server-side). Use this before authoring a full course.",
|
|
3552
|
+
{ additionalContext: z2.string().describe("What the course should teach / build") },
|
|
3553
|
+
async ({ additionalContext }) => ({
|
|
3554
|
+
content: [{ type: "text", text: instructionsToBuildCourseFromScratch(additionalContext) }]
|
|
3555
|
+
})
|
|
3556
|
+
);
|
|
3557
|
+
tool(
|
|
3558
|
+
"codevideo_create_course",
|
|
3559
|
+
"Create a new course (server-side, returns a courseId). Snapshots and lesson state live in the MCP, not in your context.",
|
|
3560
|
+
{
|
|
3561
|
+
name: z2.string(),
|
|
3562
|
+
description: z2.string().optional(),
|
|
3563
|
+
primaryLanguage: z2.string().optional(),
|
|
3564
|
+
id: z2.string().optional()
|
|
3565
|
+
},
|
|
3566
|
+
async ({ name, description, primaryLanguage, id }) => ({
|
|
3567
|
+
content: [{ type: "text", text: createCourseTool({ name, description, primaryLanguage, id }) }]
|
|
3568
|
+
})
|
|
3569
|
+
);
|
|
3570
|
+
tool(
|
|
3571
|
+
"codevideo_add_lesson",
|
|
3572
|
+
"Add a lesson to a course. By default it continues from the previous lesson's final state (chained server-side). Set mustCompile:false for slideshow/discussion/half-refactor lessons. The reply includes the previous lesson's name and description, for the recap that opens this lesson.",
|
|
3573
|
+
{
|
|
3574
|
+
courseId: z2.string(),
|
|
3575
|
+
name: z2.string(),
|
|
3576
|
+
description: z2.string().min(1, "describe the lesson in one sentence; the next lesson's recap is written from it"),
|
|
3577
|
+
mustCompile: z2.boolean().optional(),
|
|
3578
|
+
continuesFromPrevious: z2.boolean().optional()
|
|
3579
|
+
},
|
|
3580
|
+
async ({ courseId, name, description, mustCompile, continuesFromPrevious }) => ({
|
|
3581
|
+
content: [{ type: "text", text: addLessonTool(courseId, { name, description, mustCompile, continuesFromPrevious }) }]
|
|
3582
|
+
})
|
|
3583
|
+
);
|
|
3584
|
+
tool(
|
|
3585
|
+
"codevideo_set_lesson_actions",
|
|
3586
|
+
"Store a lesson's actions, replacing any it had. The server recomputes the lesson's final snapshot and re-chains following lessons. Returns only a small ack (no snapshot). To change part of a lesson later, use codevideo_edit_lesson_actions instead of sending every action again.",
|
|
3587
|
+
{
|
|
3588
|
+
courseId: z2.string(),
|
|
3589
|
+
lessonId: z2.string(),
|
|
3590
|
+
actions: z2.array(StrictActionSchema)
|
|
3591
|
+
},
|
|
3592
|
+
async ({ courseId, lessonId, actions }) => {
|
|
3593
|
+
try {
|
|
3594
|
+
return { content: [{ type: "text", text: setLessonActionsTool(courseId, lessonId, actions) }] };
|
|
3595
|
+
} catch (error) {
|
|
3596
|
+
return errorReply("Couldn't store the actions", error);
|
|
3597
|
+
}
|
|
3598
|
+
}
|
|
3599
|
+
);
|
|
3600
|
+
tool(
|
|
3601
|
+
"codevideo_edit_lesson_actions",
|
|
3602
|
+
"Change part of a lesson without re-sending it: each edit removes deleteCount actions at start and inserts the given ones there, like Array.splice. Edits apply in order (each start refers to the actions as the previous edits left them), all or nothing, with one snapshot recompute. Use codevideo_get_lesson_actions to find indices.",
|
|
3603
|
+
{
|
|
3604
|
+
courseId: z2.string(),
|
|
3605
|
+
lessonId: z2.string(),
|
|
3606
|
+
edits: z2.array(z2.object({
|
|
3607
|
+
start: z2.number().int().min(0).describe("Index of the first action to change"),
|
|
3608
|
+
deleteCount: z2.number().int().min(0).describe("How many actions to remove at start (0 to only insert)"),
|
|
3609
|
+
insert: z2.array(StrictActionSchema).default([]).describe("Actions to insert at start")
|
|
3610
|
+
})).min(1)
|
|
3611
|
+
},
|
|
3612
|
+
async ({ courseId, lessonId, edits }) => {
|
|
3613
|
+
try {
|
|
3614
|
+
return { content: [{ type: "text", text: editLessonActionsTool(courseId, lessonId, edits) }] };
|
|
3615
|
+
} catch (error) {
|
|
3616
|
+
return errorReply("Couldn't apply the edits", error);
|
|
3617
|
+
}
|
|
3618
|
+
}
|
|
3619
|
+
);
|
|
3620
|
+
tool(
|
|
3621
|
+
"codevideo_get_lesson_actions",
|
|
3622
|
+
"Read part of a lesson's actions with their indices (default: the first 50), e.g. to find what to change with codevideo_edit_lesson_actions.",
|
|
3623
|
+
{
|
|
3624
|
+
courseId: z2.string(),
|
|
3625
|
+
lessonId: z2.string(),
|
|
3626
|
+
start: z2.number().int().min(0).optional(),
|
|
3627
|
+
limit: z2.number().int().min(1).max(500).optional()
|
|
3628
|
+
},
|
|
3629
|
+
async ({ courseId, lessonId, start, limit }) => ({
|
|
3630
|
+
content: [{ type: "text", text: getLessonActionsTool(courseId, lessonId, start, limit) }]
|
|
3631
|
+
})
|
|
3632
|
+
);
|
|
3633
|
+
tool(
|
|
3634
|
+
"codevideo_get_lesson_file_tree",
|
|
3635
|
+
"List the file paths present at the END of a lesson (cheap; no file contents, no snapshot).",
|
|
3636
|
+
{ courseId: z2.string(), lessonId: z2.string() },
|
|
3637
|
+
async ({ courseId, lessonId }) => ({
|
|
3638
|
+
content: [{ type: "text", text: getLessonFileTreeTool(courseId, lessonId) }]
|
|
3639
|
+
})
|
|
3640
|
+
);
|
|
3641
|
+
tool(
|
|
3642
|
+
"codevideo_get_lesson_file",
|
|
3643
|
+
"Get the contents of ONE file at the END of a lesson (cheap targeted read instead of dumping the whole snapshot).",
|
|
3644
|
+
{ courseId: z2.string(), lessonId: z2.string(), path: z2.string() },
|
|
3645
|
+
async ({ courseId, lessonId, path: path9 }) => ({
|
|
3646
|
+
content: [{ type: "text", text: getLessonFileTool(courseId, lessonId, path9) }]
|
|
3647
|
+
})
|
|
3648
|
+
);
|
|
3649
|
+
tool(
|
|
3650
|
+
"codevideo_compile_lesson",
|
|
3651
|
+
"Compile the code at the end of a lesson and return only the structured errors. Use this to check a lesson builds before moving on.",
|
|
3652
|
+
{ courseId: z2.string(), lessonId: z2.string() },
|
|
3653
|
+
async ({ courseId, lessonId }) => ({
|
|
3654
|
+
content: [{ type: "text", text: await compileLessonTool(courseId, lessonId) }]
|
|
3655
|
+
})
|
|
3656
|
+
);
|
|
3657
|
+
tool(
|
|
3658
|
+
"codevideo_validate_course",
|
|
3659
|
+
"Validate the whole course: per-lesson action validity, snapshot continuity, and compilation (for mustCompile lessons + the final lesson). Returns done:true when the course is complete.",
|
|
3660
|
+
{ courseId: z2.string() },
|
|
3661
|
+
async ({ courseId }) => ({
|
|
3662
|
+
content: [{ type: "text", text: await validateCourseTool(courseId) }]
|
|
3663
|
+
})
|
|
3664
|
+
);
|
|
3665
|
+
tool(
|
|
3666
|
+
"codevideo_get_course_status",
|
|
3667
|
+
"Resumable checkpoint view of a course: lessons, action counts, estimated video length per lesson, flags \u2014 without pulling any snapshot.",
|
|
3668
|
+
{ courseId: z2.string() },
|
|
3669
|
+
async ({ courseId }) => ({
|
|
3670
|
+
content: [{ type: "text", text: getCourseStatusTool(courseId) }]
|
|
3671
|
+
})
|
|
3672
|
+
);
|
|
3673
|
+
tool(
|
|
3674
|
+
"codevideo_export_course",
|
|
3675
|
+
"Export the full course (ICourse with snapshots) to a JSON file under ~/.codevideo/exports and return its path. The course itself isn't returned, so it doesn't fill your context.",
|
|
3676
|
+
{ courseId: z2.string() },
|
|
3677
|
+
async ({ courseId }) => ({
|
|
3678
|
+
content: [{ type: "text", text: exportCourseTool(courseId) }]
|
|
3679
|
+
})
|
|
3680
|
+
);
|
|
3681
|
+
tool(
|
|
3682
|
+
"codevideo_generate_lesson_preview",
|
|
3683
|
+
"Generate a server-side HTML preview of a single lesson (open in a browser to eyeball it). Cheap; no video.",
|
|
3684
|
+
{ courseId: z2.string(), lessonId: z2.string() },
|
|
3685
|
+
async ({ courseId, lessonId }) => ({
|
|
3686
|
+
content: [{ type: "text", text: await generateLessonPreviewTool(courseId, lessonId) }]
|
|
3687
|
+
})
|
|
3688
|
+
);
|
|
3689
|
+
tool(
|
|
3690
|
+
"codevideo_generate_course_previews",
|
|
3691
|
+
"Generate HTML previews for every lesson \u2014 GATED: only works once codevideo_validate_course returns done. Use for human review before rendering video.",
|
|
3692
|
+
{ courseId: z2.string() },
|
|
3693
|
+
async ({ courseId }) => ({
|
|
3694
|
+
content: [{ type: "text", text: await generateCoursePreviewsTool(courseId) }]
|
|
3695
|
+
})
|
|
3696
|
+
);
|
|
3697
|
+
tool(
|
|
3698
|
+
"codevideo_render_lesson_video",
|
|
3699
|
+
"Render ONE lesson of a stored course to video: queues a background render job and returns its jobId. GATED: refuses until codevideo_validate_course returns done.",
|
|
3700
|
+
{ courseId: z2.string(), lessonId: z2.string(), options: RenderOptionsParam, open: openParam(true) },
|
|
3701
|
+
async ({ courseId, lessonId, options, open }) => {
|
|
3702
|
+
try {
|
|
3703
|
+
return { content: [{ type: "text", text: await renderLessonVideoTool(courseId, lessonId, options ?? {}, open ?? true) }] };
|
|
3704
|
+
} catch (error) {
|
|
3705
|
+
return errorReply("Couldn't queue the video", error);
|
|
3706
|
+
}
|
|
3707
|
+
}
|
|
3708
|
+
);
|
|
3709
|
+
tool(
|
|
3710
|
+
"codevideo_render_course_videos",
|
|
3711
|
+
"Render every lesson of a stored course to video: queues one background render job per lesson, run one at a time in lesson order. GATED: refuses until codevideo_validate_course returns done. Run only after a human approves the previews.",
|
|
3712
|
+
{ courseId: z2.string(), options: RenderOptionsParam, open: openParam(false) },
|
|
3713
|
+
async ({ courseId, options, open }) => {
|
|
3714
|
+
try {
|
|
3715
|
+
return { content: [{ type: "text", text: await renderCourseVideosTool(courseId, options ?? {}, open ?? false) }] };
|
|
3716
|
+
} catch (error) {
|
|
3717
|
+
return errorReply("Couldn't queue the videos", error);
|
|
3718
|
+
}
|
|
3719
|
+
}
|
|
3720
|
+
);
|
|
3721
|
+
tool(
|
|
3722
|
+
"codevideo_get_render_job",
|
|
3723
|
+
"Status of one render job: queued (with how many jobs are ahead), running (with the latest progress line), succeeded (the video exists at outputPath), failed (with the CLI's error), cancelled or interrupted. Includes the last lines of the CLI's log.",
|
|
3724
|
+
{ jobId: z2.string() },
|
|
3725
|
+
async ({ jobId }) => {
|
|
3726
|
+
const job = describeJobById(jobId, true);
|
|
3727
|
+
return job ? { content: [{ type: "text", text: JSON.stringify(job, null, 2) }] } : { isError: true, content: [{ type: "text", text: `No render job ${jobId}.` }] };
|
|
3728
|
+
}
|
|
3729
|
+
);
|
|
3730
|
+
tool(
|
|
3731
|
+
"codevideo_list_render_jobs",
|
|
3732
|
+
"Recent render jobs, newest first (default 20), optionally for one course or in one status.",
|
|
3733
|
+
{
|
|
3734
|
+
courseId: z2.string().optional(),
|
|
3735
|
+
status: z2.enum(["queued", "running", "succeeded", "failed", "cancelled", "interrupted"]).optional(),
|
|
3736
|
+
limit: z2.number().int().min(1).max(100).optional()
|
|
3737
|
+
},
|
|
3738
|
+
async ({ courseId, status, limit }) => ({
|
|
3739
|
+
content: [{ type: "text", text: JSON.stringify(listJobs({ courseId, status, limit }).map((job) => describeJob(job)), null, 2) }]
|
|
3740
|
+
})
|
|
3741
|
+
);
|
|
3742
|
+
tool(
|
|
3743
|
+
"codevideo_cancel_render_job",
|
|
3744
|
+
"Cancel one render job, or every unfinished render job of a course. Queued jobs are dropped; a running one is stopped along with its browser.",
|
|
3745
|
+
{ jobId: z2.string().optional(), courseId: z2.string().optional() },
|
|
3746
|
+
async ({ jobId, courseId }) => {
|
|
3747
|
+
if (!jobId === !courseId) {
|
|
3748
|
+
return { isError: true, content: [{ type: "text", text: "Pass exactly one of jobId or courseId." }] };
|
|
3749
|
+
}
|
|
3750
|
+
const { cancelled, stopping } = requestCancel({ jobId, courseId });
|
|
3751
|
+
return {
|
|
3752
|
+
content: [{
|
|
3753
|
+
type: "text",
|
|
3754
|
+
text: JSON.stringify({ cancelled, stopping, message: stopping > 0 ? "Stopping the running job; it shows as cancelled within a few seconds." : "Done." }, null, 2)
|
|
3755
|
+
}]
|
|
3756
|
+
};
|
|
3757
|
+
}
|
|
3758
|
+
);
|
|
3759
|
+
}
|
|
3760
|
+
var invokedPath = process.argv[1];
|
|
3761
|
+
var isDirectExecution = invokedPath !== void 0 && fs9.realpathSync(fileURLToPath(import.meta.url)) === fs9.realpathSync(invokedPath);
|
|
3762
|
+
if (isDirectExecution && process.argv.includes("--render-worker")) {
|
|
3763
|
+
runWorker().then(
|
|
3764
|
+
() => process.exit(0),
|
|
3765
|
+
(error) => {
|
|
3766
|
+
console.error(error);
|
|
3767
|
+
process.exit(1);
|
|
3768
|
+
}
|
|
3769
|
+
);
|
|
3770
|
+
} else if (isDirectExecution) {
|
|
3771
|
+
const server = createMcpServer();
|
|
3772
|
+
const transport = new StdioServerTransport();
|
|
3773
|
+
(async () => {
|
|
1371
3774
|
await server.connect(transport);
|
|
1372
|
-
})();
|
|
3775
|
+
})();
|
|
3776
|
+
}
|
|
3777
|
+
export {
|
|
3778
|
+
createMcpServer
|
|
3779
|
+
};
|
|
3780
|
+
//# sourceMappingURL=index.js.map
|