@edryslabs/edrys-dev 0.1.2 → 0.3.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 +175 -42
- package/dist/chat.js +353 -28
- package/dist/index.js +29 -5
- package/dist/init.js +108 -0
- package/dist/mcp.js +319 -38
- package/dist/modules.js +14 -3
- package/dist/origin.js +55 -3
- package/dist/server.js +28 -14
- package/dist/sync.js +9 -3
- package/dist/watch.js +18 -0
- package/package.json +2 -2
- package/views/panel.js +119 -11
package/dist/chat.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import fsp from 'node:fs/promises';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import crypto from 'node:crypto';
|
|
4
5
|
import { EventEmitter } from 'node:events';
|
|
@@ -6,59 +7,307 @@ import { createRequire } from 'node:module';
|
|
|
6
7
|
import { fileURLToPath } from 'node:url';
|
|
7
8
|
import express from 'express';
|
|
8
9
|
import { sameOrigin } from './origin.js';
|
|
9
|
-
// what
|
|
10
|
+
// what a saved chat keeps, oldest first out
|
|
10
11
|
const MAX_ENTRIES = 500;
|
|
12
|
+
// ids become file names
|
|
13
|
+
const ID = /^[\w-]{1,64}$/;
|
|
14
|
+
// what a panel gets on (re)load, in characters of JSON: 500 long messages
|
|
15
|
+
// could be 25 MB; read_chat has the rest
|
|
16
|
+
const HISTORY_MAX = 1_000_000;
|
|
17
|
+
// a burst of messages is saved once
|
|
18
|
+
const SAVE_MS = 300;
|
|
19
|
+
// The progress line says what the server knows: the message waits until an
|
|
20
|
+
// agent takes it (Chat.activity()), and after a while without any sign of the
|
|
21
|
+
// agent a hint below says so. As in LiaScript DevServer (src/lib.ts there).
|
|
22
|
+
export const WAITING = 'Waiting for the agent…';
|
|
23
|
+
export const THINKING = 'Thinking…';
|
|
24
|
+
// the server cannot know whether an idle agent listens (a channel wakes it)
|
|
25
|
+
const NO_REACTION = 'The agent has not reacted yet, maybe it is not connected. Your message is saved, it answers once it starts.';
|
|
26
|
+
const QUIET = 'The agent is still working, this can take a while.';
|
|
27
|
+
const HINT_MS = { noReaction: 30_000, quiet: 60_000 };
|
|
28
|
+
const STILL = 'The class still shows the last version that worked. Ask the agent here to fix it.';
|
|
29
|
+
/** A LabProblem from the message of edrys-Lite's DevBridge (fetchLab there). */
|
|
30
|
+
export function explain(message) {
|
|
31
|
+
// js-yaml names line and column: "could not parse <path>: <what> (4:4)\n\n<snippet>"
|
|
32
|
+
const line = /^could not parse .*?: .*? \((\d+):\d+\)/.exec(message)?.[1];
|
|
33
|
+
const text = line
|
|
34
|
+
? `There is a typing mistake in the lab file, around line ${line}.`
|
|
35
|
+
: message.startsWith('not a classroom')
|
|
36
|
+
? 'The lab file is not a lab that edrys can read.'
|
|
37
|
+
: 'The class could not load the lab file.';
|
|
38
|
+
return { text: `${text} ${STILL}`, detail: message.slice(0, 2000) };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* `values` if they answer `inputs`: only their ids, each with one of its
|
|
42
|
+
* options, a checkbox with a list of them. A radio or buttons input left
|
|
43
|
+
* empty is '' or missing.
|
|
44
|
+
*/
|
|
45
|
+
export function validAnswer(inputs, values) {
|
|
46
|
+
if (!values || typeof values !== 'object' || Array.isArray(values))
|
|
47
|
+
return null;
|
|
48
|
+
for (const [id, value] of Object.entries(values)) {
|
|
49
|
+
const input = inputs.find((i) => i.id === id);
|
|
50
|
+
if (!input)
|
|
51
|
+
return null;
|
|
52
|
+
if (input.type === 'checkbox') {
|
|
53
|
+
if (!Array.isArray(value) || new Set(value).size !== value.length)
|
|
54
|
+
return null;
|
|
55
|
+
if (!value.every((v) => input.options.includes(v)))
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
else if (value !== '' && !input.options.includes(value))
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
return values;
|
|
62
|
+
}
|
|
63
|
+
// UTC time and a random part: sorted by time, and no clash between two runs
|
|
64
|
+
function newId(t = Date.now()) {
|
|
65
|
+
return new Date(t).toISOString().slice(0, 19).replace(/:/g, '-') + '-' + crypto.randomBytes(2).toString('hex');
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The entries of a saved list, numbered upwards also if an older edrys-dev
|
|
69
|
+
* saved them without numbers; [] for a file edited by hand or of another tool.
|
|
70
|
+
*/
|
|
71
|
+
function readEntries(list) {
|
|
72
|
+
if (!Array.isArray(list))
|
|
73
|
+
return [];
|
|
74
|
+
const entries = list.filter((e) => e && typeof e === 'object' && typeof e.type === 'string').slice(-MAX_ENTRIES);
|
|
75
|
+
let next = 1;
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
if (!Number.isInteger(entry.n) || entry.n < next)
|
|
78
|
+
entry.n = next;
|
|
79
|
+
next = entry.n + 1;
|
|
80
|
+
}
|
|
81
|
+
return entries;
|
|
82
|
+
}
|
|
83
|
+
/** The first message of the user, else the first line of the agent, else the date. */
|
|
84
|
+
export function title(session) {
|
|
85
|
+
const user = session.entries.find((e) => e.type === 'user');
|
|
86
|
+
const ai = session.entries.find((e) => e.type === 'ai');
|
|
87
|
+
let text = user?.type === 'user' ? user.text : '';
|
|
88
|
+
if (!text && ai?.type === 'ai') {
|
|
89
|
+
text = ai.markdown.split('\n').map((line) => line.replace(/[#*_`>]/g, '').trim()).find(Boolean) ?? '';
|
|
90
|
+
}
|
|
91
|
+
text = text.replace(/\s+/g, ' ').trim();
|
|
92
|
+
if (!text)
|
|
93
|
+
return new Date(session.created).toISOString().slice(0, 16).replace('T', ' ');
|
|
94
|
+
return text.length > 60 ? text.slice(0, 59) + '…' : text;
|
|
95
|
+
}
|
|
96
|
+
function summary(session) {
|
|
97
|
+
return {
|
|
98
|
+
id: session.id,
|
|
99
|
+
title: title(session),
|
|
100
|
+
updated: session.entries.at(-1)?.t ?? session.created,
|
|
101
|
+
count: session.entries.length,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
11
104
|
/**
|
|
12
|
-
* The agent chat over the class tab: its
|
|
13
|
-
* .edrys-dev/
|
|
14
|
-
* (signed) chat: only the author and the agent see it.
|
|
105
|
+
* The agent chat over the class tab: its chats, saved next to the lab in
|
|
106
|
+
* .edrys-dev/chats/<id>.json, and the panel's endpoints. Not the class's own
|
|
107
|
+
* (signed) chat: only the author and the agent see it. It continues the chat
|
|
108
|
+
* that changed last; a new one gets its file with its first entry.
|
|
15
109
|
*
|
|
16
110
|
* Emits `user` (entry) when the user typed, `answer` (entry) when they
|
|
17
|
-
* answered a form,
|
|
111
|
+
* answered a form, `session` ({id, title, resumed}) when they opened another
|
|
112
|
+
* chat, and `connection` (boolean) when the panel came, or was gone for
|
|
113
|
+
* `settleMs`: a reload of the class tab is no going.
|
|
18
114
|
*/
|
|
19
115
|
export class Chat extends EventEmitter {
|
|
20
|
-
|
|
21
|
-
|
|
116
|
+
settleMs;
|
|
117
|
+
hintMs;
|
|
118
|
+
/** The chat the panel shows. */
|
|
119
|
+
session;
|
|
22
120
|
/** The one form the user can still answer. Forms of earlier runs are closed. */
|
|
23
121
|
open = null;
|
|
122
|
+
/** The inputs of the open form. */
|
|
123
|
+
form = [];
|
|
24
124
|
status = '';
|
|
125
|
+
hint = '';
|
|
126
|
+
lab = null;
|
|
127
|
+
// a message of the user waits for the first sign of an agent
|
|
128
|
+
pending = false;
|
|
129
|
+
quiet;
|
|
130
|
+
next = 1;
|
|
25
131
|
panels = new Set();
|
|
26
|
-
|
|
132
|
+
reported = false;
|
|
133
|
+
settling;
|
|
134
|
+
saveTimer;
|
|
135
|
+
// chats with entries not saved yet
|
|
136
|
+
dirty = new Set();
|
|
137
|
+
// one write after the other, never two at once into the same file
|
|
138
|
+
saving = Promise.resolve();
|
|
139
|
+
dir;
|
|
140
|
+
// the saved chats, for the menu
|
|
141
|
+
summaries = new Map();
|
|
142
|
+
// the chats read in this run: a file may still lag behind them
|
|
143
|
+
loaded = new Map();
|
|
144
|
+
/** `folder` is .edrys-dev next to the lab. */
|
|
145
|
+
constructor(folder, settleMs = 0, hintMs = HINT_MS) {
|
|
27
146
|
super();
|
|
28
|
-
this.
|
|
147
|
+
this.settleMs = settleMs;
|
|
148
|
+
this.hintMs = hintMs;
|
|
149
|
+
this.dir = path.join(folder, 'chats');
|
|
150
|
+
this.migrate(path.join(folder, 'chat.json'));
|
|
151
|
+
let names = [];
|
|
152
|
+
try {
|
|
153
|
+
names = fs.readdirSync(this.dir);
|
|
154
|
+
}
|
|
155
|
+
catch { }
|
|
156
|
+
for (const name of names) {
|
|
157
|
+
const session = name.endsWith('.json') && this.load(name.slice(0, -5));
|
|
158
|
+
if (session)
|
|
159
|
+
this.summaries.set(session.id, summary(session));
|
|
160
|
+
}
|
|
161
|
+
const latest = this.list()[0];
|
|
162
|
+
this.session = (latest && this.load(latest.id)) || { id: newId(), created: Date.now(), entries: [] };
|
|
163
|
+
this.next = (this.session.entries.at(-1)?.n ?? 0) + 1;
|
|
164
|
+
}
|
|
165
|
+
/** The entries of the chat the panel shows. */
|
|
166
|
+
get entries() {
|
|
167
|
+
return this.session.entries;
|
|
168
|
+
}
|
|
169
|
+
/** The saved chats, the one changed last first. */
|
|
170
|
+
list() {
|
|
171
|
+
return [...this.summaries.values()].sort((a, b) => b.updated - a.updated);
|
|
172
|
+
}
|
|
173
|
+
/** The chat `id`, the current one without; undefined if there is none. */
|
|
174
|
+
read(id) {
|
|
175
|
+
return id === undefined || id === this.session.id ? this.session : this.load(id);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Shows the saved chat `id`, or with null a new one (unless the current one
|
|
179
|
+
* is new still). False if there is no such chat.
|
|
180
|
+
*/
|
|
181
|
+
switchTo(id) {
|
|
182
|
+
let next;
|
|
183
|
+
if (id === null) {
|
|
184
|
+
if (!this.session.entries.length)
|
|
185
|
+
return true;
|
|
186
|
+
next = { id: newId(), created: Date.now(), entries: [] };
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
if (id === this.session.id)
|
|
190
|
+
return true;
|
|
191
|
+
next = this.summaries.has(id) ? this.load(id) : undefined;
|
|
192
|
+
if (!next)
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
// the forms and the progress line belong to the chat before
|
|
196
|
+
this.open = null;
|
|
197
|
+
this.form = [];
|
|
198
|
+
this.pending = false;
|
|
199
|
+
this.status = this.hint = '';
|
|
200
|
+
clearTimeout(this.quiet);
|
|
201
|
+
this.session = next;
|
|
202
|
+
this.next = (next.entries.at(-1)?.n ?? 0) + 1;
|
|
203
|
+
const history = `data: ${JSON.stringify(this.history())}\n\n`;
|
|
204
|
+
for (const panel of this.panels)
|
|
205
|
+
panel.write(history);
|
|
206
|
+
this.emit('session', { id: next.id, title: title(next), resumed: next.entries.length > 0 });
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
// the chat.json of an older edrys-dev becomes the first saved chat
|
|
210
|
+
migrate(file) {
|
|
29
211
|
try {
|
|
30
|
-
|
|
212
|
+
const entries = readEntries(JSON.parse(fs.readFileSync(file, 'utf8')));
|
|
213
|
+
if (!entries.length)
|
|
214
|
+
return;
|
|
215
|
+
const session = { id: newId(entries[0].t), created: entries[0].t, entries };
|
|
216
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
217
|
+
fs.writeFileSync(path.join(this.dir, session.id + '.json'), JSON.stringify(session));
|
|
218
|
+
fs.rmSync(file);
|
|
31
219
|
}
|
|
32
220
|
catch { }
|
|
33
221
|
}
|
|
222
|
+
load(id) {
|
|
223
|
+
if (!ID.test(id))
|
|
224
|
+
return undefined;
|
|
225
|
+
const known = this.loaded.get(id);
|
|
226
|
+
if (known)
|
|
227
|
+
return known;
|
|
228
|
+
try {
|
|
229
|
+
const saved = JSON.parse(fs.readFileSync(path.join(this.dir, id + '.json'), 'utf8'));
|
|
230
|
+
if (saved?.id !== id || !Array.isArray(saved.entries))
|
|
231
|
+
return undefined;
|
|
232
|
+
const entries = readEntries(saved.entries);
|
|
233
|
+
const created = Number.isFinite(saved.created) ? saved.created : entries[0]?.t ?? 0;
|
|
234
|
+
const session = { id, created, entries };
|
|
235
|
+
this.loaded.set(id, session);
|
|
236
|
+
return session;
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/** Whether a panel is open, or was until less than `settleMs` ago; what it missed comes with the history. */
|
|
34
243
|
get connected() {
|
|
35
|
-
return this.
|
|
244
|
+
return this.reported;
|
|
36
245
|
}
|
|
37
246
|
/** Posts a message; returns the id of its form if it has inputs. */
|
|
38
247
|
say(markdown, inputs = [], status) {
|
|
39
248
|
const ask = inputs.length ? 'ask-' + crypto.randomBytes(4).toString('hex') : undefined;
|
|
40
|
-
const entry = { t: Date.now(), type: 'ai', markdown, ...(ask ? { ask, inputs } : {}) };
|
|
249
|
+
const entry = { t: Date.now(), n: this.next++, type: 'ai', markdown, ...(ask ? { ask, inputs } : {}) };
|
|
41
250
|
// only the newest form stays open
|
|
42
251
|
if (ask) {
|
|
43
252
|
this.closeForm();
|
|
44
253
|
this.open = ask;
|
|
254
|
+
this.form = inputs;
|
|
45
255
|
}
|
|
46
256
|
this.add(entry);
|
|
47
257
|
this.setStatus(status ?? '');
|
|
48
258
|
return ask;
|
|
49
259
|
}
|
|
260
|
+
/** The lab file does not load (a problem), or loads again (null): the panel shows it until then. */
|
|
261
|
+
labProblem(problem) {
|
|
262
|
+
if (JSON.stringify(problem) === JSON.stringify(this.lab))
|
|
263
|
+
return;
|
|
264
|
+
this.lab = problem;
|
|
265
|
+
this.send({ type: 'lab', problem });
|
|
266
|
+
}
|
|
50
267
|
setStatus(status) {
|
|
51
268
|
this.status = status;
|
|
52
269
|
this.send({ type: 'status', text: status });
|
|
270
|
+
this.watchQuiet();
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* A sign of the agent: a tool call, or an event handed to it. A message
|
|
274
|
+
* that waited for it is now being worked on.
|
|
275
|
+
*/
|
|
276
|
+
activity() {
|
|
277
|
+
this.pending = false;
|
|
278
|
+
if (this.status === WAITING)
|
|
279
|
+
this.setStatus(THINKING);
|
|
280
|
+
else
|
|
281
|
+
this.watchQuiet();
|
|
282
|
+
}
|
|
283
|
+
/** The entries of the user (typed or answered) after entry `n`. */
|
|
284
|
+
newerThan(n) {
|
|
285
|
+
return this.entries.filter((e) => e.n > n && e.type !== 'ai');
|
|
53
286
|
}
|
|
54
287
|
/** Withdraws the open form; returns its id, or null if there was none. */
|
|
55
|
-
closeForm() {
|
|
288
|
+
closeForm(reason) {
|
|
56
289
|
const closed = this.open;
|
|
57
290
|
this.open = null;
|
|
58
291
|
if (closed)
|
|
59
|
-
this.send({ type: 'close', ask: closed });
|
|
292
|
+
this.send({ type: 'close', ask: closed, ...(reason ? { reason } : {}) });
|
|
60
293
|
return closed;
|
|
61
294
|
}
|
|
295
|
+
// restarts the clock whenever something happens while a progress line shows
|
|
296
|
+
watchQuiet() {
|
|
297
|
+
clearTimeout(this.quiet);
|
|
298
|
+
if (this.hint)
|
|
299
|
+
this.send({ type: 'hint', text: (this.hint = '') });
|
|
300
|
+
if (!this.status)
|
|
301
|
+
return;
|
|
302
|
+
this.quiet = setTimeout(() => this.send({ type: 'hint', text: (this.hint = this.pending ? NO_REACTION : QUIET) }), this.pending ? this.hintMs.noReaction : this.hintMs.quiet);
|
|
303
|
+
// a pending hint must not keep a stopped edrys-dev alive
|
|
304
|
+
this.quiet.unref();
|
|
305
|
+
}
|
|
306
|
+
// the user sees at once that the message arrived, until the agent takes it
|
|
307
|
+
waitForAgent() {
|
|
308
|
+
this.pending = true;
|
|
309
|
+
this.setStatus(WAITING);
|
|
310
|
+
}
|
|
62
311
|
routes(app) {
|
|
63
312
|
app.get('/edrys-dev/chat/events', (req, res) => {
|
|
64
313
|
res.writeHead(200, {
|
|
@@ -66,31 +315,50 @@ export class Chat extends EventEmitter {
|
|
|
66
315
|
'Cache-Control': 'no-cache',
|
|
67
316
|
Connection: 'keep-alive',
|
|
68
317
|
});
|
|
69
|
-
// a reload shows the
|
|
70
|
-
res.write(`data: ${JSON.stringify(
|
|
318
|
+
// a reload shows the chat again, with the open form and status
|
|
319
|
+
res.write(`data: ${JSON.stringify(this.history())}\n\n`);
|
|
71
320
|
this.panels.add(res);
|
|
72
|
-
|
|
73
|
-
|
|
321
|
+
clearTimeout(this.settling);
|
|
322
|
+
if (!this.reported)
|
|
323
|
+
this.emit('connection', (this.reported = true));
|
|
74
324
|
req.on('close', () => {
|
|
75
325
|
this.panels.delete(res);
|
|
76
|
-
if (this.panels.size
|
|
77
|
-
|
|
326
|
+
if (this.panels.size)
|
|
327
|
+
return;
|
|
328
|
+
clearTimeout(this.settling);
|
|
329
|
+
this.settling = setTimeout(() => {
|
|
330
|
+
if (!this.panels.size && this.reported)
|
|
331
|
+
this.emit('connection', (this.reported = false));
|
|
332
|
+
}, this.settleMs);
|
|
78
333
|
});
|
|
79
334
|
});
|
|
80
335
|
app.post('/edrys-dev/chat', express.json(), (req, res) => {
|
|
81
336
|
if (!sameOrigin(req))
|
|
82
337
|
return res.sendStatus(403);
|
|
83
338
|
const { text, ask, values } = req.body ?? {};
|
|
339
|
+
// the menu: another saved chat, or null for a new one
|
|
340
|
+
if (req.body && Object.hasOwn(req.body, 'session')) {
|
|
341
|
+
const { session } = req.body;
|
|
342
|
+
if (session !== null && typeof session !== 'string')
|
|
343
|
+
return res.sendStatus(400);
|
|
344
|
+
return res.sendStatus(this.switchTo(session) ? 204 : 404);
|
|
345
|
+
}
|
|
84
346
|
if (typeof text === 'string' && text.trim()) {
|
|
85
|
-
const entry = { t: Date.now(), type: 'user', text: text.slice(0, 10000) };
|
|
347
|
+
const entry = { t: Date.now(), n: this.next++, type: 'user', text: text.slice(0, 10000) };
|
|
86
348
|
this.add(entry);
|
|
349
|
+
this.waitForAgent();
|
|
87
350
|
this.emit('user', entry);
|
|
88
351
|
return res.sendStatus(204);
|
|
89
352
|
}
|
|
90
|
-
if (typeof ask === 'string' && ask === this.open
|
|
353
|
+
if (typeof ask === 'string' && ask === this.open) {
|
|
354
|
+
// the agent gets what its form offered, or nothing
|
|
355
|
+
const answer = validAnswer(this.form, values);
|
|
356
|
+
if (!answer)
|
|
357
|
+
return res.sendStatus(400);
|
|
91
358
|
this.open = null;
|
|
92
|
-
const entry = { t: Date.now(), type: 'answer', ask, values };
|
|
359
|
+
const entry = { t: Date.now(), n: this.next++, type: 'answer', ask, values: answer };
|
|
93
360
|
this.add(entry);
|
|
361
|
+
this.waitForAgent();
|
|
94
362
|
this.emit('answer', entry);
|
|
95
363
|
return res.sendStatus(204);
|
|
96
364
|
}
|
|
@@ -99,20 +367,77 @@ export class Chat extends EventEmitter {
|
|
|
99
367
|
});
|
|
100
368
|
}
|
|
101
369
|
add(entry) {
|
|
102
|
-
this.
|
|
103
|
-
|
|
104
|
-
|
|
370
|
+
const session = this.session;
|
|
371
|
+
session.entries.push(entry);
|
|
372
|
+
if (session.entries.length > MAX_ENTRIES)
|
|
373
|
+
session.entries.splice(0, session.entries.length - MAX_ENTRIES);
|
|
374
|
+
this.loaded.set(session.id, session);
|
|
105
375
|
this.send({ type: 'entry', entry });
|
|
376
|
+
// the menu changes with a new chat's first entry, or its first message of the user
|
|
377
|
+
const before = this.summaries.get(session.id)?.title;
|
|
378
|
+
this.summaries.set(session.id, summary(session));
|
|
379
|
+
if (this.summaries.get(session.id).title !== before)
|
|
380
|
+
this.send({ type: 'sessions', sessions: this.list() });
|
|
106
381
|
this.save();
|
|
107
382
|
}
|
|
383
|
+
// what a panel gets first, and after a switch
|
|
384
|
+
history() {
|
|
385
|
+
const entries = this.recent();
|
|
386
|
+
return {
|
|
387
|
+
type: 'history',
|
|
388
|
+
session: { id: this.session.id, title: title(this.session) },
|
|
389
|
+
sessions: this.list(),
|
|
390
|
+
entries,
|
|
391
|
+
skipped: this.entries.length - entries.length,
|
|
392
|
+
open: this.open,
|
|
393
|
+
status: this.status,
|
|
394
|
+
hint: this.hint,
|
|
395
|
+
lab: this.lab,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
108
398
|
send(message) {
|
|
109
399
|
for (const panel of this.panels)
|
|
110
400
|
panel.write(`data: ${JSON.stringify(message)}\n\n`);
|
|
111
401
|
}
|
|
402
|
+
/** The newest entries that fit into HISTORY_MAX, at least the last one. */
|
|
403
|
+
recent() {
|
|
404
|
+
let start = this.entries.length;
|
|
405
|
+
let size = 0;
|
|
406
|
+
while (start > 0) {
|
|
407
|
+
size += JSON.stringify(this.entries[start - 1]).length;
|
|
408
|
+
if (size > HISTORY_MAX && start < this.entries.length)
|
|
409
|
+
break;
|
|
410
|
+
start--;
|
|
411
|
+
}
|
|
412
|
+
return this.entries.slice(start);
|
|
413
|
+
}
|
|
112
414
|
save() {
|
|
415
|
+
this.dirty.add(this.session);
|
|
416
|
+
this.saveTimer ??= setTimeout(() => this.writeDirty(), SAVE_MS);
|
|
417
|
+
}
|
|
418
|
+
writeDirty() {
|
|
419
|
+
clearTimeout(this.saveTimer);
|
|
420
|
+
this.saveTimer = undefined;
|
|
421
|
+
const sessions = [...this.dirty];
|
|
422
|
+
this.dirty.clear();
|
|
423
|
+
this.saving = this.saving.then(async () => {
|
|
424
|
+
for (const session of sessions)
|
|
425
|
+
await this.write(session);
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
/** Writes what is not saved yet, e.g. before edrys-dev exits. */
|
|
429
|
+
async flush() {
|
|
430
|
+
if (this.saveTimer)
|
|
431
|
+
this.writeDirty();
|
|
432
|
+
await this.saving;
|
|
433
|
+
}
|
|
434
|
+
// a copy renamed over the file: a crash midway leaves the old chat, not half a one
|
|
435
|
+
async write(session) {
|
|
436
|
+
const file = path.join(this.dir, session.id + '.json');
|
|
113
437
|
try {
|
|
114
|
-
|
|
115
|
-
|
|
438
|
+
await fsp.mkdir(this.dir, { recursive: true });
|
|
439
|
+
await fsp.writeFile(file + '.tmp', JSON.stringify(session));
|
|
440
|
+
await fsp.rename(file + '.tmp', file);
|
|
116
441
|
}
|
|
117
442
|
catch (error) {
|
|
118
443
|
console.error('edrys-dev: could not save the chat:', error.message);
|
package/dist/index.js
CHANGED
|
@@ -5,13 +5,18 @@ import { parseArgs } from 'node:util';
|
|
|
5
5
|
import open from 'open';
|
|
6
6
|
import { startDevServer } from './server.js';
|
|
7
7
|
import { startMcp } from './mcp.js';
|
|
8
|
+
import { reachable } from './origin.js';
|
|
8
9
|
const HELP = `edrys-dev: develop an edrys-Lite lab locally
|
|
9
10
|
|
|
10
11
|
Usage: edrys-dev [lab.yml] [options]
|
|
12
|
+
edrys-dev init [folder] set up a new lab for an AI agent: lab.yml,
|
|
13
|
+
agent config and instructions
|
|
11
14
|
|
|
12
15
|
-i, --input <file> lab file (YAML or JSON), default ./lab.yml
|
|
13
16
|
-p, --port <port> port, default 3210 (the next free one if taken)
|
|
14
|
-
--host <host> address to listen on, default 127.0.0.1
|
|
17
|
+
--host <host> address to listen on, default 127.0.0.1; another one
|
|
18
|
+
(e.g. 0.0.0.0) lets others on the network in, with
|
|
19
|
+
--mcp also into the agent chat
|
|
15
20
|
--local sync on this server instead of edrys-Lite's default
|
|
16
21
|
transport (works offline)
|
|
17
22
|
-m, --modules <dir> local modules, one per subfolder, served on the next
|
|
@@ -43,7 +48,18 @@ if (values.help) {
|
|
|
43
48
|
say(HELP);
|
|
44
49
|
process.exit(0);
|
|
45
50
|
}
|
|
51
|
+
if (positionals[0] === 'init' && !values.input) {
|
|
52
|
+
const { init } = await import('./init.js');
|
|
53
|
+
await init(positionals[1] ?? '.');
|
|
54
|
+
process.exit(0);
|
|
55
|
+
}
|
|
46
56
|
const lab = path.resolve(values.input ?? positionals[0] ?? 'lab.yml');
|
|
57
|
+
// an agent started in an empty folder gets a lab to work on, not a failed server
|
|
58
|
+
if (!fs.existsSync(lab) && values.mcp && /\.ya?ml$/i.test(lab) && fs.statSync(path.dirname(lab), { throwIfNoEntry: false })?.isDirectory()) {
|
|
59
|
+
const { starterLab } = await import('./init.js');
|
|
60
|
+
fs.writeFileSync(lab, starterLab(path.basename(path.dirname(lab))), { flag: 'wx' });
|
|
61
|
+
say(`edrys-dev: no lab file yet, created ${lab}`);
|
|
62
|
+
}
|
|
47
63
|
if (!fs.existsSync(lab)) {
|
|
48
64
|
console.error(`edrys-dev: no lab file at ${lab}\n\n${HELP}`);
|
|
49
65
|
process.exit(1);
|
|
@@ -62,6 +78,14 @@ const server = await startDevServer({
|
|
|
62
78
|
modules,
|
|
63
79
|
agent: values.mcp,
|
|
64
80
|
});
|
|
81
|
+
// Ctrl+C, or a process manager or agent client stopping it; the chat is saved
|
|
82
|
+
// first. Before anything is printed: whoever waits for that may stop it at once.
|
|
83
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
84
|
+
process.once(signal, async () => {
|
|
85
|
+
await server.close();
|
|
86
|
+
process.exit(0);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
65
89
|
server.bus.on('report', (report) => {
|
|
66
90
|
switch (report.type) {
|
|
67
91
|
case 'open':
|
|
@@ -77,11 +101,11 @@ const url = `http://${host}:${server.port}/?dev`;
|
|
|
77
101
|
say(`edrys-dev: ${lab}\n ${url}`);
|
|
78
102
|
if (server.modules)
|
|
79
103
|
say(`modules: ${server.modules.dir}\n ${server.modules.url}<name>/...`);
|
|
104
|
+
if (reachable(values.host)) {
|
|
105
|
+
console.error(`edrys-dev: listening on ${values.host}, not only on this machine: others on the network can open the class` +
|
|
106
|
+
(values.mcp ? ' and type to the agent in its chat' : ''));
|
|
107
|
+
}
|
|
80
108
|
if (values.mcp)
|
|
81
109
|
startMcp(server, { lab, url });
|
|
82
110
|
if (values.open)
|
|
83
111
|
await open(url);
|
|
84
|
-
process.on('SIGINT', async () => {
|
|
85
|
-
await server.close();
|
|
86
|
-
process.exit(0);
|
|
87
|
-
});
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// edrys-dev init: a lab folder that an AI agent can start in, without writing
|
|
2
|
+
// any config by hand. Files that exist are kept.
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import readline from 'node:readline/promises';
|
|
8
|
+
const SERVER = { command: 'npx', args: ['-y', '@edryslabs/edrys-dev', '--mcp', 'lab.yml'] };
|
|
9
|
+
const SETTINGS = {
|
|
10
|
+
enabledMcpjsonServers: ['edrys'],
|
|
11
|
+
permissions: { allow: ['mcp__edrys', 'Edit(/lab.yml)', 'Edit(/modules/**)'] },
|
|
12
|
+
};
|
|
13
|
+
/** A lab with a welcome page; also what `--mcp` starts with when there is no lab file. */
|
|
14
|
+
export const starterLab = (name) => `name: ${JSON.stringify(name)}
|
|
15
|
+
meta:
|
|
16
|
+
description: Made with edrys-dev.
|
|
17
|
+
modules:
|
|
18
|
+
- url: https://edrys-labs.github.io/module-markdown-it/index.html
|
|
19
|
+
config:
|
|
20
|
+
content: |
|
|
21
|
+
# Welcome
|
|
22
|
+
|
|
23
|
+
Open the 🤖 chat (bottom right) and tell the agent what this lab should do.
|
|
24
|
+
width: full
|
|
25
|
+
height: tall
|
|
26
|
+
`;
|
|
27
|
+
const agents = (name) => `# ${name}
|
|
28
|
+
|
|
29
|
+
This folder is an edrys-Lite lab: \`lab.yml\` is the classroom configuration, \`modules/\` (if there) holds modules written for it. The user works in the class in their browser and talks to you through the chat there; they may not look at this session.
|
|
30
|
+
|
|
31
|
+
When a session starts:
|
|
32
|
+
|
|
33
|
+
1. Call \`get_state\` of the edrys MCP server. If \`connected\` is false, open its \`url\` in the browser (browser automation or the system's open command), or tell the user the address.
|
|
34
|
+
2. If \`messages\` is above 0, call \`read_chat\` and pick up where the chat left off.
|
|
35
|
+
3. Serve the chat until the user says stop: answer every chat event with the \`chat\` tool, as the edrys server's instructions say.
|
|
36
|
+
|
|
37
|
+
The user may not be a programmer. Answer in plain words, show no YAML or code in the chat unless they ask, and offer choices as chat forms (buttons).
|
|
38
|
+
|
|
39
|
+
If the edrys-lab and edrys-module skills are installed, use them to write the lab and its modules.
|
|
40
|
+
`;
|
|
41
|
+
const FILES = [
|
|
42
|
+
['lab.yml', starterLab],
|
|
43
|
+
['.mcp.json', () => JSON.stringify({ mcpServers: { edrys: SERVER } }, null, 2) + '\n'],
|
|
44
|
+
['.vscode/mcp.json', () => JSON.stringify({ servers: { edrys: { type: 'stdio', ...SERVER } } }, null, 2) + '\n'],
|
|
45
|
+
['AGENTS.md', agents],
|
|
46
|
+
// Claude Code reads CLAUDE.md, which imports AGENTS.md
|
|
47
|
+
['CLAUDE.md', () => '@AGENTS.md\n'],
|
|
48
|
+
// no prompts for the edrys server and for edits of the lab and its modules
|
|
49
|
+
['.claude/settings.json', () => JSON.stringify(SETTINGS, null, 2) + '\n'],
|
|
50
|
+
];
|
|
51
|
+
// The edrys skills, for every lab of this user: in ~/.agents/skills, which
|
|
52
|
+
// Copilot reads, linked into ~/.claude/skills. In the lab folder they would be
|
|
53
|
+
// published with it.
|
|
54
|
+
const SKILLS = ['-y', 'skills', 'add', 'edrys-labs/skills', '-g', '--skill', '*', '--agent', 'claude-code', 'github-copilot', '-y'];
|
|
55
|
+
const SKILLS_HINT = 'npx skills add edrys-labs/skills';
|
|
56
|
+
function skillsInstalled() {
|
|
57
|
+
return ['.claude', '.agents'].some((dir) => ['edrys-lab', 'edrys-module'].every((name) => fs.existsSync(path.join(os.homedir(), dir, 'skills', name))));
|
|
58
|
+
}
|
|
59
|
+
async function ask(question) {
|
|
60
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
61
|
+
const answer = (await rl.question(question)).trim();
|
|
62
|
+
rl.close();
|
|
63
|
+
return answer;
|
|
64
|
+
}
|
|
65
|
+
export async function init(folder, say = console.log) {
|
|
66
|
+
const dir = path.resolve(folder);
|
|
67
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
68
|
+
let name = path.basename(dir);
|
|
69
|
+
if (process.stdin.isTTY && !fs.existsSync(path.join(dir, 'lab.yml'))) {
|
|
70
|
+
name = (await ask(`Name of your lab (${name}): `)) || name;
|
|
71
|
+
}
|
|
72
|
+
for (const [file, content] of FILES) {
|
|
73
|
+
const target = path.join(dir, file);
|
|
74
|
+
if (fs.existsSync(target)) {
|
|
75
|
+
say(` kept ${file} (already there)`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
79
|
+
fs.writeFileSync(target, content(name));
|
|
80
|
+
say(` created ${file}`);
|
|
81
|
+
}
|
|
82
|
+
const ignore = path.join(dir, '.gitignore');
|
|
83
|
+
const lines = fs.existsSync(ignore) ? fs.readFileSync(ignore, 'utf8') : null;
|
|
84
|
+
if (lines === null) {
|
|
85
|
+
fs.writeFileSync(ignore, '.edrys-dev/\n');
|
|
86
|
+
say(' created .gitignore');
|
|
87
|
+
}
|
|
88
|
+
else if (!lines.split(/\r?\n/).some((line) => ['.edrys-dev', '.edrys-dev/', '/.edrys-dev/'].includes(line.trim()))) {
|
|
89
|
+
fs.appendFileSync(ignore, (lines === '' || lines.endsWith('\n') ? '' : '\n') + '.edrys-dev/\n');
|
|
90
|
+
say(' added .edrys-dev/ to .gitignore');
|
|
91
|
+
}
|
|
92
|
+
if (!skillsInstalled()) {
|
|
93
|
+
const yes = process.stdin.isTTY &&
|
|
94
|
+
/^(y|yes|)$/i.test(await ask('\nInstall the edrys skills, so your agent knows how labs and modules are written? (Y/n) '));
|
|
95
|
+
// npx is npx.cmd on Windows, which needs a shell
|
|
96
|
+
const run = yes && spawnSync('npx', SKILLS, { stdio: 'inherit', shell: process.platform === 'win32' });
|
|
97
|
+
if (run && run.status === 0)
|
|
98
|
+
say(' installed the edrys skills');
|
|
99
|
+
else
|
|
100
|
+
say(`\n${run ? 'Could not install the edrys skills. ' : ''}To install them later: ${SKILLS_HINT}`);
|
|
101
|
+
}
|
|
102
|
+
say(`
|
|
103
|
+
Your lab is ready in ${dir}
|
|
104
|
+
|
|
105
|
+
Next: open this folder in your AI agent (Claude Code, or VS Code with Copilot)
|
|
106
|
+
and write "start". The agent starts edrys-dev and opens the class in your
|
|
107
|
+
browser; from then on you can talk to it in the 🤖 chat there.`);
|
|
108
|
+
}
|