@edryslabs/edrys-dev 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +163 -43
- package/dist/chat.js +332 -28
- package/dist/index.js +16 -5
- package/dist/mcp.js +319 -38
- package/dist/modules.js +14 -3
- package/dist/origin.js +55 -3
- package/dist/server.js +22 -13
- package/dist/sync.js +9 -3
- package/dist/watch.js +18 -0
- package/package.json +2 -2
- package/views/panel.js +92 -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,42 +7,238 @@ 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 };
|
|
11
28
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
29
|
+
* `values` if they answer `inputs`: only their ids, each with one of its
|
|
30
|
+
* options, a checkbox with a list of them. A radio or buttons input left
|
|
31
|
+
* empty is '' or missing.
|
|
32
|
+
*/
|
|
33
|
+
export function validAnswer(inputs, values) {
|
|
34
|
+
if (!values || typeof values !== 'object' || Array.isArray(values))
|
|
35
|
+
return null;
|
|
36
|
+
for (const [id, value] of Object.entries(values)) {
|
|
37
|
+
const input = inputs.find((i) => i.id === id);
|
|
38
|
+
if (!input)
|
|
39
|
+
return null;
|
|
40
|
+
if (input.type === 'checkbox') {
|
|
41
|
+
if (!Array.isArray(value) || new Set(value).size !== value.length)
|
|
42
|
+
return null;
|
|
43
|
+
if (!value.every((v) => input.options.includes(v)))
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
else if (value !== '' && !input.options.includes(value))
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return values;
|
|
50
|
+
}
|
|
51
|
+
// UTC time and a random part: sorted by time, and no clash between two runs
|
|
52
|
+
function newId(t = Date.now()) {
|
|
53
|
+
return new Date(t).toISOString().slice(0, 19).replace(/:/g, '-') + '-' + crypto.randomBytes(2).toString('hex');
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The entries of a saved list, numbered upwards also if an older edrys-dev
|
|
57
|
+
* saved them without numbers; [] for a file edited by hand or of another tool.
|
|
58
|
+
*/
|
|
59
|
+
function readEntries(list) {
|
|
60
|
+
if (!Array.isArray(list))
|
|
61
|
+
return [];
|
|
62
|
+
const entries = list.filter((e) => e && typeof e === 'object' && typeof e.type === 'string').slice(-MAX_ENTRIES);
|
|
63
|
+
let next = 1;
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
if (!Number.isInteger(entry.n) || entry.n < next)
|
|
66
|
+
entry.n = next;
|
|
67
|
+
next = entry.n + 1;
|
|
68
|
+
}
|
|
69
|
+
return entries;
|
|
70
|
+
}
|
|
71
|
+
/** The first message of the user, else the first line of the agent, else the date. */
|
|
72
|
+
export function title(session) {
|
|
73
|
+
const user = session.entries.find((e) => e.type === 'user');
|
|
74
|
+
const ai = session.entries.find((e) => e.type === 'ai');
|
|
75
|
+
let text = user?.type === 'user' ? user.text : '';
|
|
76
|
+
if (!text && ai?.type === 'ai') {
|
|
77
|
+
text = ai.markdown.split('\n').map((line) => line.replace(/[#*_`>]/g, '').trim()).find(Boolean) ?? '';
|
|
78
|
+
}
|
|
79
|
+
text = text.replace(/\s+/g, ' ').trim();
|
|
80
|
+
if (!text)
|
|
81
|
+
return new Date(session.created).toISOString().slice(0, 16).replace('T', ' ');
|
|
82
|
+
return text.length > 60 ? text.slice(0, 59) + '…' : text;
|
|
83
|
+
}
|
|
84
|
+
function summary(session) {
|
|
85
|
+
return {
|
|
86
|
+
id: session.id,
|
|
87
|
+
title: title(session),
|
|
88
|
+
updated: session.entries.at(-1)?.t ?? session.created,
|
|
89
|
+
count: session.entries.length,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The agent chat over the class tab: its chats, saved next to the lab in
|
|
94
|
+
* .edrys-dev/chats/<id>.json, and the panel's endpoints. Not the class's own
|
|
95
|
+
* (signed) chat: only the author and the agent see it. It continues the chat
|
|
96
|
+
* that changed last; a new one gets its file with its first entry.
|
|
15
97
|
*
|
|
16
98
|
* Emits `user` (entry) when the user typed, `answer` (entry) when they
|
|
17
|
-
* answered a form,
|
|
99
|
+
* answered a form, `session` ({id, title, resumed}) when they opened another
|
|
100
|
+
* chat, and `connection` (boolean) when the panel came, or was gone for
|
|
101
|
+
* `settleMs`: a reload of the class tab is no going.
|
|
18
102
|
*/
|
|
19
103
|
export class Chat extends EventEmitter {
|
|
20
|
-
|
|
21
|
-
|
|
104
|
+
settleMs;
|
|
105
|
+
hintMs;
|
|
106
|
+
/** The chat the panel shows. */
|
|
107
|
+
session;
|
|
22
108
|
/** The one form the user can still answer. Forms of earlier runs are closed. */
|
|
23
109
|
open = null;
|
|
110
|
+
/** The inputs of the open form. */
|
|
111
|
+
form = [];
|
|
24
112
|
status = '';
|
|
113
|
+
hint = '';
|
|
114
|
+
// a message of the user waits for the first sign of an agent
|
|
115
|
+
pending = false;
|
|
116
|
+
quiet;
|
|
117
|
+
next = 1;
|
|
25
118
|
panels = new Set();
|
|
26
|
-
|
|
119
|
+
reported = false;
|
|
120
|
+
settling;
|
|
121
|
+
saveTimer;
|
|
122
|
+
// chats with entries not saved yet
|
|
123
|
+
dirty = new Set();
|
|
124
|
+
// one write after the other, never two at once into the same file
|
|
125
|
+
saving = Promise.resolve();
|
|
126
|
+
dir;
|
|
127
|
+
// the saved chats, for the menu
|
|
128
|
+
summaries = new Map();
|
|
129
|
+
// the chats read in this run: a file may still lag behind them
|
|
130
|
+
loaded = new Map();
|
|
131
|
+
/** `folder` is .edrys-dev next to the lab. */
|
|
132
|
+
constructor(folder, settleMs = 0, hintMs = HINT_MS) {
|
|
27
133
|
super();
|
|
28
|
-
this.
|
|
134
|
+
this.settleMs = settleMs;
|
|
135
|
+
this.hintMs = hintMs;
|
|
136
|
+
this.dir = path.join(folder, 'chats');
|
|
137
|
+
this.migrate(path.join(folder, 'chat.json'));
|
|
138
|
+
let names = [];
|
|
139
|
+
try {
|
|
140
|
+
names = fs.readdirSync(this.dir);
|
|
141
|
+
}
|
|
142
|
+
catch { }
|
|
143
|
+
for (const name of names) {
|
|
144
|
+
const session = name.endsWith('.json') && this.load(name.slice(0, -5));
|
|
145
|
+
if (session)
|
|
146
|
+
this.summaries.set(session.id, summary(session));
|
|
147
|
+
}
|
|
148
|
+
const latest = this.list()[0];
|
|
149
|
+
this.session = (latest && this.load(latest.id)) || { id: newId(), created: Date.now(), entries: [] };
|
|
150
|
+
this.next = (this.session.entries.at(-1)?.n ?? 0) + 1;
|
|
151
|
+
}
|
|
152
|
+
/** The entries of the chat the panel shows. */
|
|
153
|
+
get entries() {
|
|
154
|
+
return this.session.entries;
|
|
155
|
+
}
|
|
156
|
+
/** The saved chats, the one changed last first. */
|
|
157
|
+
list() {
|
|
158
|
+
return [...this.summaries.values()].sort((a, b) => b.updated - a.updated);
|
|
159
|
+
}
|
|
160
|
+
/** The chat `id`, the current one without; undefined if there is none. */
|
|
161
|
+
read(id) {
|
|
162
|
+
return id === undefined || id === this.session.id ? this.session : this.load(id);
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Shows the saved chat `id`, or with null a new one (unless the current one
|
|
166
|
+
* is new still). False if there is no such chat.
|
|
167
|
+
*/
|
|
168
|
+
switchTo(id) {
|
|
169
|
+
let next;
|
|
170
|
+
if (id === null) {
|
|
171
|
+
if (!this.session.entries.length)
|
|
172
|
+
return true;
|
|
173
|
+
next = { id: newId(), created: Date.now(), entries: [] };
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
if (id === this.session.id)
|
|
177
|
+
return true;
|
|
178
|
+
next = this.summaries.has(id) ? this.load(id) : undefined;
|
|
179
|
+
if (!next)
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
// the forms and the progress line belong to the chat before
|
|
183
|
+
this.open = null;
|
|
184
|
+
this.form = [];
|
|
185
|
+
this.pending = false;
|
|
186
|
+
this.status = this.hint = '';
|
|
187
|
+
clearTimeout(this.quiet);
|
|
188
|
+
this.session = next;
|
|
189
|
+
this.next = (next.entries.at(-1)?.n ?? 0) + 1;
|
|
190
|
+
const history = `data: ${JSON.stringify(this.history())}\n\n`;
|
|
191
|
+
for (const panel of this.panels)
|
|
192
|
+
panel.write(history);
|
|
193
|
+
this.emit('session', { id: next.id, title: title(next), resumed: next.entries.length > 0 });
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
// the chat.json of an older edrys-dev becomes the first saved chat
|
|
197
|
+
migrate(file) {
|
|
29
198
|
try {
|
|
30
|
-
|
|
199
|
+
const entries = readEntries(JSON.parse(fs.readFileSync(file, 'utf8')));
|
|
200
|
+
if (!entries.length)
|
|
201
|
+
return;
|
|
202
|
+
const session = { id: newId(entries[0].t), created: entries[0].t, entries };
|
|
203
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
204
|
+
fs.writeFileSync(path.join(this.dir, session.id + '.json'), JSON.stringify(session));
|
|
205
|
+
fs.rmSync(file);
|
|
31
206
|
}
|
|
32
207
|
catch { }
|
|
33
208
|
}
|
|
209
|
+
load(id) {
|
|
210
|
+
if (!ID.test(id))
|
|
211
|
+
return undefined;
|
|
212
|
+
const known = this.loaded.get(id);
|
|
213
|
+
if (known)
|
|
214
|
+
return known;
|
|
215
|
+
try {
|
|
216
|
+
const saved = JSON.parse(fs.readFileSync(path.join(this.dir, id + '.json'), 'utf8'));
|
|
217
|
+
if (saved?.id !== id || !Array.isArray(saved.entries))
|
|
218
|
+
return undefined;
|
|
219
|
+
const entries = readEntries(saved.entries);
|
|
220
|
+
const created = Number.isFinite(saved.created) ? saved.created : entries[0]?.t ?? 0;
|
|
221
|
+
const session = { id, created, entries };
|
|
222
|
+
this.loaded.set(id, session);
|
|
223
|
+
return session;
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/** Whether a panel is open, or was until less than `settleMs` ago; what it missed comes with the history. */
|
|
34
230
|
get connected() {
|
|
35
|
-
return this.
|
|
231
|
+
return this.reported;
|
|
36
232
|
}
|
|
37
233
|
/** Posts a message; returns the id of its form if it has inputs. */
|
|
38
234
|
say(markdown, inputs = [], status) {
|
|
39
235
|
const ask = inputs.length ? 'ask-' + crypto.randomBytes(4).toString('hex') : undefined;
|
|
40
|
-
const entry = { t: Date.now(), type: 'ai', markdown, ...(ask ? { ask, inputs } : {}) };
|
|
236
|
+
const entry = { t: Date.now(), n: this.next++, type: 'ai', markdown, ...(ask ? { ask, inputs } : {}) };
|
|
41
237
|
// only the newest form stays open
|
|
42
238
|
if (ask) {
|
|
43
239
|
this.closeForm();
|
|
44
240
|
this.open = ask;
|
|
241
|
+
this.form = inputs;
|
|
45
242
|
}
|
|
46
243
|
this.add(entry);
|
|
47
244
|
this.setStatus(status ?? '');
|
|
@@ -50,15 +247,47 @@ export class Chat extends EventEmitter {
|
|
|
50
247
|
setStatus(status) {
|
|
51
248
|
this.status = status;
|
|
52
249
|
this.send({ type: 'status', text: status });
|
|
250
|
+
this.watchQuiet();
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* A sign of the agent: a tool call, or an event handed to it. A message
|
|
254
|
+
* that waited for it is now being worked on.
|
|
255
|
+
*/
|
|
256
|
+
activity() {
|
|
257
|
+
this.pending = false;
|
|
258
|
+
if (this.status === WAITING)
|
|
259
|
+
this.setStatus(THINKING);
|
|
260
|
+
else
|
|
261
|
+
this.watchQuiet();
|
|
262
|
+
}
|
|
263
|
+
/** The entries of the user (typed or answered) after entry `n`. */
|
|
264
|
+
newerThan(n) {
|
|
265
|
+
return this.entries.filter((e) => e.n > n && e.type !== 'ai');
|
|
53
266
|
}
|
|
54
267
|
/** Withdraws the open form; returns its id, or null if there was none. */
|
|
55
|
-
closeForm() {
|
|
268
|
+
closeForm(reason) {
|
|
56
269
|
const closed = this.open;
|
|
57
270
|
this.open = null;
|
|
58
271
|
if (closed)
|
|
59
|
-
this.send({ type: 'close', ask: closed });
|
|
272
|
+
this.send({ type: 'close', ask: closed, ...(reason ? { reason } : {}) });
|
|
60
273
|
return closed;
|
|
61
274
|
}
|
|
275
|
+
// restarts the clock whenever something happens while a progress line shows
|
|
276
|
+
watchQuiet() {
|
|
277
|
+
clearTimeout(this.quiet);
|
|
278
|
+
if (this.hint)
|
|
279
|
+
this.send({ type: 'hint', text: (this.hint = '') });
|
|
280
|
+
if (!this.status)
|
|
281
|
+
return;
|
|
282
|
+
this.quiet = setTimeout(() => this.send({ type: 'hint', text: (this.hint = this.pending ? NO_REACTION : QUIET) }), this.pending ? this.hintMs.noReaction : this.hintMs.quiet);
|
|
283
|
+
// a pending hint must not keep a stopped edrys-dev alive
|
|
284
|
+
this.quiet.unref();
|
|
285
|
+
}
|
|
286
|
+
// the user sees at once that the message arrived, until the agent takes it
|
|
287
|
+
waitForAgent() {
|
|
288
|
+
this.pending = true;
|
|
289
|
+
this.setStatus(WAITING);
|
|
290
|
+
}
|
|
62
291
|
routes(app) {
|
|
63
292
|
app.get('/edrys-dev/chat/events', (req, res) => {
|
|
64
293
|
res.writeHead(200, {
|
|
@@ -66,31 +295,50 @@ export class Chat extends EventEmitter {
|
|
|
66
295
|
'Cache-Control': 'no-cache',
|
|
67
296
|
Connection: 'keep-alive',
|
|
68
297
|
});
|
|
69
|
-
// a reload shows the
|
|
70
|
-
res.write(`data: ${JSON.stringify(
|
|
298
|
+
// a reload shows the chat again, with the open form and status
|
|
299
|
+
res.write(`data: ${JSON.stringify(this.history())}\n\n`);
|
|
71
300
|
this.panels.add(res);
|
|
72
|
-
|
|
73
|
-
|
|
301
|
+
clearTimeout(this.settling);
|
|
302
|
+
if (!this.reported)
|
|
303
|
+
this.emit('connection', (this.reported = true));
|
|
74
304
|
req.on('close', () => {
|
|
75
305
|
this.panels.delete(res);
|
|
76
|
-
if (this.panels.size
|
|
77
|
-
|
|
306
|
+
if (this.panels.size)
|
|
307
|
+
return;
|
|
308
|
+
clearTimeout(this.settling);
|
|
309
|
+
this.settling = setTimeout(() => {
|
|
310
|
+
if (!this.panels.size && this.reported)
|
|
311
|
+
this.emit('connection', (this.reported = false));
|
|
312
|
+
}, this.settleMs);
|
|
78
313
|
});
|
|
79
314
|
});
|
|
80
315
|
app.post('/edrys-dev/chat', express.json(), (req, res) => {
|
|
81
316
|
if (!sameOrigin(req))
|
|
82
317
|
return res.sendStatus(403);
|
|
83
318
|
const { text, ask, values } = req.body ?? {};
|
|
319
|
+
// the menu: another saved chat, or null for a new one
|
|
320
|
+
if (req.body && Object.hasOwn(req.body, 'session')) {
|
|
321
|
+
const { session } = req.body;
|
|
322
|
+
if (session !== null && typeof session !== 'string')
|
|
323
|
+
return res.sendStatus(400);
|
|
324
|
+
return res.sendStatus(this.switchTo(session) ? 204 : 404);
|
|
325
|
+
}
|
|
84
326
|
if (typeof text === 'string' && text.trim()) {
|
|
85
|
-
const entry = { t: Date.now(), type: 'user', text: text.slice(0, 10000) };
|
|
327
|
+
const entry = { t: Date.now(), n: this.next++, type: 'user', text: text.slice(0, 10000) };
|
|
86
328
|
this.add(entry);
|
|
329
|
+
this.waitForAgent();
|
|
87
330
|
this.emit('user', entry);
|
|
88
331
|
return res.sendStatus(204);
|
|
89
332
|
}
|
|
90
|
-
if (typeof ask === 'string' && ask === this.open
|
|
333
|
+
if (typeof ask === 'string' && ask === this.open) {
|
|
334
|
+
// the agent gets what its form offered, or nothing
|
|
335
|
+
const answer = validAnswer(this.form, values);
|
|
336
|
+
if (!answer)
|
|
337
|
+
return res.sendStatus(400);
|
|
91
338
|
this.open = null;
|
|
92
|
-
const entry = { t: Date.now(), type: 'answer', ask, values };
|
|
339
|
+
const entry = { t: Date.now(), n: this.next++, type: 'answer', ask, values: answer };
|
|
93
340
|
this.add(entry);
|
|
341
|
+
this.waitForAgent();
|
|
94
342
|
this.emit('answer', entry);
|
|
95
343
|
return res.sendStatus(204);
|
|
96
344
|
}
|
|
@@ -99,20 +347,76 @@ export class Chat extends EventEmitter {
|
|
|
99
347
|
});
|
|
100
348
|
}
|
|
101
349
|
add(entry) {
|
|
102
|
-
this.
|
|
103
|
-
|
|
104
|
-
|
|
350
|
+
const session = this.session;
|
|
351
|
+
session.entries.push(entry);
|
|
352
|
+
if (session.entries.length > MAX_ENTRIES)
|
|
353
|
+
session.entries.splice(0, session.entries.length - MAX_ENTRIES);
|
|
354
|
+
this.loaded.set(session.id, session);
|
|
105
355
|
this.send({ type: 'entry', entry });
|
|
356
|
+
// the menu changes with a new chat's first entry, or its first message of the user
|
|
357
|
+
const before = this.summaries.get(session.id)?.title;
|
|
358
|
+
this.summaries.set(session.id, summary(session));
|
|
359
|
+
if (this.summaries.get(session.id).title !== before)
|
|
360
|
+
this.send({ type: 'sessions', sessions: this.list() });
|
|
106
361
|
this.save();
|
|
107
362
|
}
|
|
363
|
+
// what a panel gets first, and after a switch
|
|
364
|
+
history() {
|
|
365
|
+
const entries = this.recent();
|
|
366
|
+
return {
|
|
367
|
+
type: 'history',
|
|
368
|
+
session: { id: this.session.id, title: title(this.session) },
|
|
369
|
+
sessions: this.list(),
|
|
370
|
+
entries,
|
|
371
|
+
skipped: this.entries.length - entries.length,
|
|
372
|
+
open: this.open,
|
|
373
|
+
status: this.status,
|
|
374
|
+
hint: this.hint,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
108
377
|
send(message) {
|
|
109
378
|
for (const panel of this.panels)
|
|
110
379
|
panel.write(`data: ${JSON.stringify(message)}\n\n`);
|
|
111
380
|
}
|
|
381
|
+
/** The newest entries that fit into HISTORY_MAX, at least the last one. */
|
|
382
|
+
recent() {
|
|
383
|
+
let start = this.entries.length;
|
|
384
|
+
let size = 0;
|
|
385
|
+
while (start > 0) {
|
|
386
|
+
size += JSON.stringify(this.entries[start - 1]).length;
|
|
387
|
+
if (size > HISTORY_MAX && start < this.entries.length)
|
|
388
|
+
break;
|
|
389
|
+
start--;
|
|
390
|
+
}
|
|
391
|
+
return this.entries.slice(start);
|
|
392
|
+
}
|
|
112
393
|
save() {
|
|
394
|
+
this.dirty.add(this.session);
|
|
395
|
+
this.saveTimer ??= setTimeout(() => this.writeDirty(), SAVE_MS);
|
|
396
|
+
}
|
|
397
|
+
writeDirty() {
|
|
398
|
+
clearTimeout(this.saveTimer);
|
|
399
|
+
this.saveTimer = undefined;
|
|
400
|
+
const sessions = [...this.dirty];
|
|
401
|
+
this.dirty.clear();
|
|
402
|
+
this.saving = this.saving.then(async () => {
|
|
403
|
+
for (const session of sessions)
|
|
404
|
+
await this.write(session);
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
/** Writes what is not saved yet, e.g. before edrys-dev exits. */
|
|
408
|
+
async flush() {
|
|
409
|
+
if (this.saveTimer)
|
|
410
|
+
this.writeDirty();
|
|
411
|
+
await this.saving;
|
|
412
|
+
}
|
|
413
|
+
// a copy renamed over the file: a crash midway leaves the old chat, not half a one
|
|
414
|
+
async write(session) {
|
|
415
|
+
const file = path.join(this.dir, session.id + '.json');
|
|
113
416
|
try {
|
|
114
|
-
|
|
115
|
-
|
|
417
|
+
await fsp.mkdir(this.dir, { recursive: true });
|
|
418
|
+
await fsp.writeFile(file + '.tmp', JSON.stringify(session));
|
|
419
|
+
await fsp.rename(file + '.tmp', file);
|
|
116
420
|
}
|
|
117
421
|
catch (error) {
|
|
118
422
|
console.error('edrys-dev: could not save the chat:', error.message);
|
package/dist/index.js
CHANGED
|
@@ -5,13 +5,16 @@ 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]
|
|
11
12
|
|
|
12
13
|
-i, --input <file> lab file (YAML or JSON), default ./lab.yml
|
|
13
14
|
-p, --port <port> port, default 3210 (the next free one if taken)
|
|
14
|
-
--host <host> address to listen on, default 127.0.0.1
|
|
15
|
+
--host <host> address to listen on, default 127.0.0.1; another one
|
|
16
|
+
(e.g. 0.0.0.0) lets others on the network in, with
|
|
17
|
+
--mcp also into the agent chat
|
|
15
18
|
--local sync on this server instead of edrys-Lite's default
|
|
16
19
|
transport (works offline)
|
|
17
20
|
-m, --modules <dir> local modules, one per subfolder, served on the next
|
|
@@ -62,6 +65,14 @@ const server = await startDevServer({
|
|
|
62
65
|
modules,
|
|
63
66
|
agent: values.mcp,
|
|
64
67
|
});
|
|
68
|
+
// Ctrl+C, or a process manager or agent client stopping it; the chat is saved
|
|
69
|
+
// first. Before anything is printed: whoever waits for that may stop it at once.
|
|
70
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
71
|
+
process.once(signal, async () => {
|
|
72
|
+
await server.close();
|
|
73
|
+
process.exit(0);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
65
76
|
server.bus.on('report', (report) => {
|
|
66
77
|
switch (report.type) {
|
|
67
78
|
case 'open':
|
|
@@ -77,11 +88,11 @@ const url = `http://${host}:${server.port}/?dev`;
|
|
|
77
88
|
say(`edrys-dev: ${lab}\n ${url}`);
|
|
78
89
|
if (server.modules)
|
|
79
90
|
say(`modules: ${server.modules.dir}\n ${server.modules.url}<name>/...`);
|
|
91
|
+
if (reachable(values.host)) {
|
|
92
|
+
console.error(`edrys-dev: listening on ${values.host}, not only on this machine: others on the network can open the class` +
|
|
93
|
+
(values.mcp ? ' and type to the agent in its chat' : ''));
|
|
94
|
+
}
|
|
80
95
|
if (values.mcp)
|
|
81
96
|
startMcp(server, { lab, url });
|
|
82
97
|
if (values.open)
|
|
83
98
|
await open(url);
|
|
84
|
-
process.on('SIGINT', async () => {
|
|
85
|
-
await server.close();
|
|
86
|
-
process.exit(0);
|
|
87
|
-
});
|