clavarium 0.1.5 → 0.2.0-next.2

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.
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.1.5",
3
+ "version": "0.2.0-next.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "clavarium",
9
- "version": "0.1.5",
9
+ "version": "0.2.0-next.2",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
13
13
  "@xterm/xterm": "^6.0.0",
14
- "clavarium-parley": "0.9.0",
14
+ "clavarium-parley": "0.10.0-next.2",
15
15
  "mermaid": "11.17.2",
16
16
  "node-pty": "^1.1.0",
17
17
  "pdfjs-dist": "6.4.299",
@@ -686,9 +686,9 @@
686
686
  }
687
687
  },
688
688
  "node_modules/clavarium-parley": {
689
- "version": "0.9.0",
690
- "resolved": "https://registry.npmjs.org/clavarium-parley/-/clavarium-parley-0.9.0.tgz",
691
- "integrity": "sha512-9JjM2GVlAq/a3n3G47QG/MSze8RjwiDH4aMqGQhJ92T4W/uDlRjCJiRwVyuPWx7PVAId/+r8sHNIPDAz7K0tzQ==",
689
+ "version": "0.10.0-next.2",
690
+ "resolved": "https://registry.npmjs.org/clavarium-parley/-/clavarium-parley-0.10.0-next.2.tgz",
691
+ "integrity": "sha512-/6pAEMEKgug1FerqBKzqoMcWmnR6eYWtfiGt1Oa1gz9vWFuupxXlTeUFurukgfqLiEVOU6U+g1qJUff8PtTLBQ==",
692
692
  "license": "MIT",
693
693
  "bin": {
694
694
  "parley": "bin/parley.mjs",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.1.5",
3
+ "version": "0.2.0-next.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A conversation is a VM with a terminal in it. Everything else is a folder.",
@@ -43,7 +43,7 @@
43
43
  "dependencies": {
44
44
  "@xterm/addon-fit": "^0.11.0",
45
45
  "@xterm/xterm": "^6.0.0",
46
- "clavarium-parley": "0.9.0",
46
+ "clavarium-parley": "0.10.0-next.2",
47
47
  "mermaid": "11.17.2",
48
48
  "node-pty": "^1.1.0",
49
49
  "pdfjs-dist": "6.4.299",
package/server/api.mjs CHANGED
@@ -37,6 +37,8 @@
37
37
  * POST /api/updates/apply { version }: update to the version that is out (in the background)
38
38
  * POST /api/updates/back { version }: go back to a release that is kept (in the background)
39
39
  * GET /api/git/<name>/outgoing?target=<branch>&branch=<branch> what would go from the agent's branch, to that branch of the remote
40
+ * GET /api/git/<name>/graph the branch diagram: branches and their last commits
41
+ * GET /api/git/<name>/commit?id=<sha> one commit, as a review shows it
40
42
  * POST /api/git/<name>/push {head, target?, sure?}: exactly that reviewed commit, to exactly that branch
41
43
  * GET /api/git/<name>/keep origin kept up to date, checks, automatic sending, log
42
44
  * POST /api/git/<name>/keep {refreshMinutes?, alert?, checkCommand?, checkSeconds?, runHook?, requireForSend?, fsck?}
@@ -215,6 +217,8 @@ export async function handleApi(request, response, conversations, headers, resou
215
217
  if (answered && answerer) return send(200, answerer.log(decodeURIComponent(answered[1]))), true;
216
218
  /* A repository: where its commits go, and what would go now. */
217
219
  const repo = /^\/api\/git\/([a-z0-9-]{1,32})(\/outgoing|\/keep)?$/.exec(pathname);
220
+ const drawn = /^\/api\/git\/([a-z0-9-]{1,32})\/(graph|commit)$/.exec(pathname);
221
+ if (drawn && gitReview) return send(200, drawn[2] === 'graph' ? await gitReview.graph(drawn[1]) : await gitReview.commit(drawn[1], query.get('id'))), true;
218
222
  if (repo && repo[2] === '/keep' && gitKeeper) return send(200, gitKeeper.about(repo[1])), true;
219
223
  if (repo && gitReview) return send(200, repo[2] ? await gitReview.outgoing(repo[1], { target: query.get('target') ?? undefined, branch: query.get('branch') ?? undefined }) : gitReview.settings(repo[1])), true;
220
224
  const watched = /^\/api\/conversations\/([^/]+)\/signin$/.exec(pathname);
@@ -424,6 +428,10 @@ export async function handleApi(request, response, conversations, headers, resou
424
428
  if (asking) return send(200, conversations.setAskFirst(decodeURIComponent(asking[1]), body.on)), true;
425
429
  const naming = /^\/api\/conversations\/([^/]+)\/chat-name$/.exec(pathname);
426
430
  if (naming) return send(200, conversations.setChatName(decodeURIComponent(naming[1]), body.name)), true;
431
+ const titling = /^\/api\/conversations\/([^/]+)\/title$/.exec(pathname);
432
+ if (titling) return send(200, conversations.setTitle(decodeURIComponent(titling[1]), body.title)), true;
433
+ const hiding = /^\/api\/conversations\/([^/]+)\/hidden$/.exec(pathname);
434
+ if (hiding) return send(200, conversations.setHidden(decodeURIComponent(hiding[1]), body.on)), true;
427
435
  const marking = /^\/api\/conversations\/([^/]+)\/voice-mark$/.exec(pathname);
428
436
  if (marking) return send(200, conversations.setVoiceMark(decodeURIComponent(marking[1]), body.on)), true;
429
437
  const resuming = /^\/api\/conversations\/([^/]+)\/resume$/.exec(pathname);
@@ -6,7 +6,7 @@
6
6
  * - Sleep is a button, never a guess: telling a long job from an idle tool
7
7
  * cannot be done without reading the tool, and this server does not read.
8
8
  */
9
- import { isConversationName } from './names.mjs';
9
+ import { isConversationName, identifierFrom, titleProblem } from './names.mjs';
10
10
  import { attachCommand, SOCKET } from './tmux.mjs';
11
11
  import { WORK, VM_USER, SHARING_STEP } from './vm.mjs';
12
12
  import { resumeOrFresh } from './tools.mjs';
@@ -189,7 +189,8 @@ export class Conversations {
189
189
 
190
190
  /* Whether a new machine may be made for this request: what `create`
191
191
  checks, and the server's room (capacity.mjs). Asked before `create`. */
192
- async admit({ name, tool, memoryMb } = {}) {
192
+ async admit({ name, title, tool, memoryMb } = {}) {
193
+ if (!name && typeof title === 'string' && !titleProblem(title)) ({ name } = this.#naming({ title }));
193
194
  const chosen = this.#tools.find((t) => t.id === tool);
194
195
  /* What `create` refuses anyway is said by `create`. */
195
196
  if (!chosen || !isConversationName(name) || this.#store.get(name)) return;
@@ -201,7 +202,17 @@ export class Conversations {
201
202
  /* `workOnDisk`: the tool works on its machine's own disk instead of in a
202
203
  folder kept by the server. Faster for a large code project; but what
203
204
  it makes is then not seen from the page, and goes with the machine. */
204
- create({ name, tool, memoryMb, workOnDisk }) {
205
+ /* A conversation is made from the name a person reads (`title`), its
206
+ identifier drawn from it, or from an identifier alone (the advanced
207
+ options, a blueprint). The identifier never changes afterwards. */
208
+ #naming({ name, title }) {
209
+ if (title !== undefined) { const problem = titleProblem(title); if (problem) throw new Refusal(400, problem); }
210
+ const id = name || (title !== undefined ? identifierFrom(title, (x) => Boolean(this.#store.get(x))) : name);
211
+ return { name: id, title: title !== undefined && title !== id ? title : undefined };
212
+ }
213
+
214
+ create({ name: given, title: read, tool, memoryMb, workOnDisk }) {
215
+ const { name, title } = this.#naming({ name: given, title: read });
205
216
  if (!isConversationName(name)) throw new Refusal(400, 'A name is 1 to 32 lower-case letters, digits or hyphens; it starts with a letter and does not end with a hyphen.');
206
217
  const chosen = this.#tools.find((t) => t.id === tool);
207
218
  if (!chosen) throw new Refusal(400, 'Unknown tool.');
@@ -211,7 +222,7 @@ export class Conversations {
211
222
  }
212
223
  if (this.#store.get(name)) throw new Refusal(409, 'A conversation already has this name.');
213
224
  const now = new Date().toISOString();
214
- const conversation = { name, tool, memoryMb: memory, diskGb: DISK_GB, fixed: FIXES, work: workOnDisk === true ? 'disk' : 'kept', phase: 'building', step: 'Starting the machine', createdAt: now, buildingSince: now, progress: buildProgress(now) };
225
+ const conversation = { name, ...(title ? { title } : {}), tool, memoryMb: memory, diskGb: DISK_GB, fixed: FIXES, work: workOnDisk === true ? 'disk' : 'kept', phase: 'building', step: 'Starting the machine', createdAt: now, buildingSince: now, progress: buildProgress(now) };
215
226
  this.#store.put(conversation);
216
227
  this.built = this.#build(name, chosen, memory);
217
228
  return conversation;
@@ -450,6 +461,28 @@ export class Conversations {
450
461
  return this.#store.get(name);
451
462
  }
452
463
 
464
+ /* THE NAME A PERSON READS, changed at any time; its identifier stays.
465
+ Kept only when it differs from the identifier: a conversation made
466
+ before free names is read by its identifier, and nothing is rewritten. */
467
+ setTitle(name, title) {
468
+ const c = this.#store.get(name);
469
+ if (!c) throw new Refusal(404, 'No such conversation.');
470
+ const problem = titleProblem(title);
471
+ if (problem) throw new Refusal(400, problem);
472
+ this.#store.patch(name, { title: title === name ? undefined : title });
473
+ return this.#store.get(name);
474
+ }
475
+
476
+ /* Hidden from the list, like a hidden file: it runs, keeps its rights,
477
+ and is still in Access; the list says how many are hidden. */
478
+ setHidden(name, on) {
479
+ const c = this.#store.get(name);
480
+ if (!c) throw new Refusal(404, 'No such conversation.');
481
+ if (typeof on !== 'boolean') throw new Refusal(400, 'Say true (hidden from the list) or false (shown).');
482
+ this.#store.patch(name, { hidden: on ? true : undefined });
483
+ return this.#store.get(name);
484
+ }
485
+
453
486
  /* What is dictated into its terminal starts with a microphone sign, once
454
487
  per message (true, the default for a tool that is an AI), or not. */
455
488
  setVoiceMark(name, on) {
@@ -40,6 +40,9 @@ export const UNCHANGED_MAX = 50;
40
40
  /* A commit message is shown up to this many characters, and the cut is said. */
41
41
  export const MESSAGE_MAX = 4000;
42
42
  export const REVIEWS_KEPT = 20;
43
+ /* The branch diagram: so many commits, so many of the remote's branches. */
44
+ export const GRAPH_COMMITS = 40;
45
+ export const GRAPH_BRANCHES = 20;
43
46
  /* How long a review waits for the reference, and a send or a refresh. */
44
47
  export const REVIEW_WAIT_MS = 10_000;
45
48
  export const SEND_WAIT_MS = 60_000;
@@ -408,6 +411,66 @@ export class GitReview {
408
411
  } finally { got.remove(); }
409
412
  }
410
413
 
414
+ /* The workspace of one read, with the remote's history lent to it when
415
+ there is one, for `work(ws, remoteTips)`; deleted after. */
416
+ async #withRead(r, work) {
417
+ const got = await this.#read(r);
418
+ try {
419
+ let release = null; let remoteTips = new Map();
420
+ if (this.#remoteOf(r) && this.#resources.hasOrigin(r.name)) {
421
+ release = await this.#runner.lock(this.#reference(r.name), REVIEW_WAIT_MS);
422
+ this.#runner.borrow(got.path, this.#reference(r.name));
423
+ remoteTips = await this.#tipsIn(this.#reference(r.name));
424
+ }
425
+ try { return await work(got, remoteTips); } finally { release?.(); }
426
+ } finally { got.remove(); }
427
+ }
428
+
429
+ /* THE BRANCH DIAGRAM: the agent's branches, the remote's, and their
430
+ last commits, newest first in topological order: { branches: [{name,
431
+ id, side: 'agent'|'remote'}], commits: [{id, parents, subject, date,
432
+ refs: [{name, side}]}] }. What the agent wrote is escaped as everywhere. */
433
+ async graph(name) {
434
+ const r = this.#repository(name);
435
+ try {
436
+ return await this.#withRead(r, async (got, remoteTips) => {
437
+ const branches = got.branches.map((b) => ({ name: escaped(b.name), id: b.id, side: 'agent' }));
438
+ for (const [b, id] of [...remoteTips].sort(([a], [c]) => a.localeCompare(c)).slice(0, GRAPH_BRANCHES)) branches.push({ name: escaped(b), id, side: 'remote' });
439
+ const tips = [...new Set(branches.map((b) => b.id))];
440
+ if (tips.length === 0) return { branches, commits: [] };
441
+ const log = await this.#runner.in(got.path, ['log', '--topo-order', `--max-count=${GRAPH_COMMITS}`, '-z', '--format=%H%x00%P%x00%aI%x00%s', '--stdin', '--end-of-options'], { input: `${tips.join('\n')}\n`, deadlineMs: 60_000, maxBytes: 4 * 1024 * 1024 });
442
+ const fields = log.stdout.toString('utf8').split('\0');
443
+ const commits = [];
444
+ for (let i = 0; i + 3 < fields.length; i += 4) {
445
+ const id = fields[i].replace(/^\n/, '');
446
+ if (!isId(id)) break;
447
+ commits.push({ id, parents: fields[i + 1].split(' ').filter(isId), date: fields[i + 2], subject: escaped(fields[i + 3]).slice(0, 300),
448
+ refs: branches.filter((b) => b.id === id).map((b) => ({ name: b.name, side: b.side })) });
449
+ }
450
+ return { branches, commits };
451
+ });
452
+ } catch (error) {
453
+ if (error instanceof GitFailure && error.kind === 'empty') return { branches: [], commits: [] };
454
+ throw error;
455
+ }
456
+ }
457
+
458
+ /* ONE COMMIT, as a review shows it: against its first parent (or
459
+ nothing, for the first commit). Never sent from here. */
460
+ async commit(name, id) {
461
+ const r = this.#repository(name);
462
+ const asked = String(id ?? '');
463
+ if (!isId(asked)) throw new Refusal(400, 'A commit is named by its full identifier.');
464
+ return this.#withRead(r, async (got) => {
465
+ const p = await this.#runner.in(got.path, ['rev-list', '--parents', '--max-count=1', '--end-of-options', asked], { deadlineMs: 60_000 });
466
+ const ids = p.stdout.toString('latin1').trim().split(' ');
467
+ if (p.code !== 0 || ids[0] !== asked) throw new Refusal(404, 'This commit is not in the repository.');
468
+ const parents = ids.slice(1).filter(isId);
469
+ const shown = await this.#compare(got.path, { head: asked, base: parents[0] ?? EMPTY_TREE, range: [asked], negatives: parents });
470
+ return { ...shown, id: asked, parents, head: asked, state: 'commit', message: parents.length > 1 ? 'A merge: its changes against its first parent.' : '' };
471
+ });
472
+ }
473
+
411
474
  /* What a review shows of head against base, in a workspace: the commits
412
475
  (with their whole messages), the true count, the files, the changes,
413
476
  and what the changes alone could hide. `range` and `negatives` say
package/server/main.mjs CHANGED
@@ -70,6 +70,10 @@ export const FILES = {
70
70
  '/voice-core.js': ['web/voice-core.js', 'text/javascript; charset=utf-8'],
71
71
  '/icons.js': ['web/icons.js', 'text/javascript; charset=utf-8'],
72
72
  '/links.js': ['web/links.js', 'text/javascript; charset=utf-8'],
73
+ '/gitgraph.js': ['web/gitgraph.js', 'text/javascript; charset=utf-8'],
74
+ /* The review's diff, drawn by diff2html (vendored, pinned: web/vendor/diff2html/LICENSE). */
75
+ '/vendor/diff2html.js': ['web/vendor/diff2html/diff2html-ui-slim.min.js', 'text/javascript; charset=utf-8'],
76
+ '/vendor/diff2html.css': ['web/vendor/diff2html/diff2html.min.css', 'text/css; charset=utf-8'],
73
77
  '/vendor/xterm.js': [packageFile('@xterm/xterm', 'lib/xterm.js'), 'text/javascript; charset=utf-8'],
74
78
  '/vendor/xterm.css': [packageFile('@xterm/xterm', 'css/xterm.css'), 'text/css; charset=utf-8'],
75
79
  '/vendor/fit.js': [packageFile('@xterm/addon-fit', 'lib/addon-fit.js'), 'text/javascript; charset=utf-8'],
package/server/names.mjs CHANGED
@@ -8,3 +8,34 @@ const NAME = /^[a-z](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
8
8
  export function isConversationName(value) {
9
9
  return typeof value === 'string' && value.length <= NAME_MAX && NAME.test(value);
10
10
  }
11
+
12
+ /* THE NAME A PERSON READS is free: spaces, capitals, accents, emoji. It never
13
+ reaches a command line (the identifier above does), and the page shows it
14
+ as text only. Refused: what cannot be seen or reorders what is seen
15
+ (control characters, line breaks, zero-width and bidirectional marks), and
16
+ spaces at either end, which would make two names look alike. */
17
+ export const TITLE_MAX = 60;
18
+ const UNSEEN_RANGES = [[0x0, 0x1f], [0x7f, 0x9f], [0xad, 0xad], [0x61c, 0x61c], [0x180e, 0x180e], [0x200b, 0x200f], [0x2028, 0x202e], [0x2060, 0x206f], [0xfeff, 0xfeff], [0xfff9, 0xfffb]];
19
+ const UNSEEN = new RegExp(`[${UNSEEN_RANGES.map(([a, b]) => `\\u{${a.toString(16)}}-\\u{${b.toString(16)}}`).join('')}]`, 'u');
20
+
21
+ /* The reason it is refused, or '' when it may be a name. */
22
+ export function titleProblem(value) {
23
+ if (typeof value !== 'string' || value === '') return 'Give a name.';
24
+ if (UNSEEN.test(value)) return 'A name has no line breaks and no invisible or direction characters.';
25
+ if (value.trim() !== value) return 'A name does not start or end with a space.';
26
+ if ([...value].length > TITLE_MAX) return `A name is ${TITLE_MAX} characters at most.`;
27
+ return '';
28
+ }
29
+
30
+ /* The identifier drawn from a name: "Location voiture" -> location-voiture;
31
+ one taken -> location-voiture-2. `taken(id)` says whether it is. */
32
+ export function identifierFrom(title, taken = () => false) {
33
+ let base = String(title ?? '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase()
34
+ .replace(/[^a-z0-9]+/g, '-').replace(/^[^a-z]+/, '').replace(/-+$/, '');
35
+ if (!base) base = 'conversation';
36
+ for (let n = 1; ; n += 1) {
37
+ const tail = n === 1 ? '' : `-${n}`;
38
+ const id = `${base.slice(0, NAME_MAX - tail.length).replace(/-+$/, '')}${tail}`;
39
+ if (isConversationName(id) && !taken(id)) return id;
40
+ }
41
+ }
@@ -89,7 +89,7 @@ export class Toolbox {
89
89
  const sha256 = createHash('sha256').update(bytes).digest('hex');
90
90
  const there = existsSync(`${dir}/${file}`) ? createHash('sha256').update(readFileSync(`${dir}/${file}`)).digest('hex') : '';
91
91
  if (there !== sha256) { writeFileSync(`${dir}/.${file}.new`, bytes); renameSync(`${dir}/.${file}.new`, `${dir}/${file}`); }
92
- const version = /-(\d+\.\d+\.\d+)\.tgz$/.exec(file)?.[1] ?? '';
92
+ const version = /-(\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?)\.tgz$/.exec(file)?.[1] ?? '';
93
93
  tools.push({ name: source.name, version, file, sha256, usage: source.usage(`${TOOLS_PATH}/${file}`) });
94
94
  }
95
95
  const kept = new Set([...tools.map((t) => t.file), 'index.json', ...(this.#notes ? [NOTES] : [])]);