@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/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 the saved chat keeps, oldest first out
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
- * The agent chat over the class tab: its history, saved next to the lab in
13
- * .edrys-dev/chat.json, and the panel's endpoints. Not the class's own
14
- * (signed) chat: only the author and the agent see it.
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, and `connection` (boolean) when the panel came or went.
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
- file;
21
- entries = [];
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
- constructor(file) {
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.file = file;
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
- this.entries = JSON.parse(fs.readFileSync(file, 'utf8'));
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.panels.size > 0;
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 whole chat again, with the open form and status
70
- res.write(`data: ${JSON.stringify({ type: 'history', entries: this.entries, open: this.open, status: this.status })}\n\n`);
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
- if (this.panels.size === 1)
73
- this.emit('connection', true);
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 === 0)
77
- this.emit('connection', false);
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 && values && typeof values === 'object') {
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.entries.push(entry);
103
- if (this.entries.length > MAX_ENTRIES)
104
- this.entries.splice(0, this.entries.length - MAX_ENTRIES);
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
- fs.mkdirSync(path.dirname(this.file), { recursive: true });
115
- fs.writeFileSync(this.file, JSON.stringify(this.entries));
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
- });