clavarium 0.2.366-next.1 → 0.2.368-next.1

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,12 +1,12 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.2.366-next.1",
3
+ "version": "0.2.368-next.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "clavarium",
9
- "version": "0.2.366-next.1",
9
+ "version": "0.2.368-next.1",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.2.366-next.1",
3
+ "version": "0.2.368-next.1",
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.",
package/server/api.mjs CHANGED
@@ -94,6 +94,7 @@
94
94
  * GET /api/rooms the rooms (#268): [{id, name, host, members: [{id, kind, name, ref, host?}], unread, last, thinking: [names]}]
95
95
  * POST /api/rooms {name, conversations: [names], boxes: [ids], host?}: a new room
96
96
  * POST /api/rooms/<id> {name?, host?, add?: {conversations, boxes}, remove?: [member ids]}
97
+ * quiet?: true/false (muted in the list: no dot, at the bottom)
97
98
  * POST /api/rooms/<id>/remove the room and its thread
98
99
  * POST /api/rooms/<id>/give {asset, rights, shared?}: the same right given to each AI of it, its own
99
100
  * conversation's -> {asset, rights, given: [{conversation, ok, kept?, error?}]}
@@ -699,6 +700,8 @@ export async function handleApi(request, response, conversations, headers, resou
699
700
  if (access !== 'write') gitKeeper?.forgetAttachment(name, conversation);
700
701
  await resources.attach(conversation, name, access);
701
702
  retell(conversation);
703
+ /* Newly attached: its links looked at (and its mounts), not waited for. */
704
+ if (access !== 'none') void health?.check?.(conversation, { asked: true })?.catch?.(() => {});
702
705
  };
703
706
  const handSecret = async (conversation, name, attached) => {
704
707
  if (!conversations.list().some((c) => c.name === conversation)) throw new Refusal(404, 'No such conversation.');
@@ -867,6 +870,13 @@ export async function handleApi(request, response, conversations, headers, resou
867
870
  if (!conversations.list().some((c) => c.name === conversation)) throw new Refusal(404, 'No such conversation.');
868
871
  return send(200, health.markSeen(conversation)), true;
869
872
  }
873
+ /* "Check shared folders": each mounted and readable (put back when loose), each link the share cannot serve (health.mjs). */
874
+ const foldersOf = /^\/api\/conversations\/([^/]+)\/folders\/check$/.exec(pathname);
875
+ if (foldersOf && health) {
876
+ const conversation = decodeURIComponent(foldersOf[1]);
877
+ if (!conversations.list().some((c) => c.name === conversation)) throw new Refusal(404, 'No such conversation.');
878
+ return send(200, await health.checkFolders(conversation)), true;
879
+ }
870
880
  const machine = /^\/api\/conversations\/([^/]+)\/(restart|machine|health)$/.exec(pathname);
871
881
  if (machine) {
872
882
  const conversation = decodeURIComponent(machine[1]);
package/server/health.mjs CHANGED
@@ -24,6 +24,7 @@
24
24
  should be mounted; they are passed as arguments, never parsed by the
25
25
  shell. It prints one fact per line; the server reads only these lines. */
26
26
  import { SHARING_STEP } from './vm.mjs';
27
+ import { WHY } from './linkguard.mjs';
27
28
 
28
29
  /* How long installing the shared-folder helper in a machine may take (it comes from the snap store). */
29
30
  export const SHARING_MS = 180_000;
@@ -123,13 +124,16 @@ export function judge(record) {
123
124
  for (const { path, why } of record.failed ?? []) add('bad', 'mount', `failed:${path}`, `${path} is not reachable in the machine and could not be mounted again${why ? `: ${why}` : ''}.`);
124
125
  for (const path of record.missing ?? []) if (!(record.failed ?? []).some((f) => f.path === path)) add('warn', 'mount', `missing:${path}`, `${path} is not reachable in the machine.`);
125
126
  }
127
+ /* Links its folders hold that the share cannot serve (linkguard.mjs): looked at on this server, whatever its machine does. */
128
+ for (const l of record.links?.removed ?? []) add('info', 'link', `link-removed:${l.folder}/${l.path}`, `/resources/${l.folder}/${l.path} was a broken link (${WHY[l.kind] ?? l.kind}): it was removed on the server, what it pointed to left as it was.`);
129
+ for (const l of record.links?.found ?? []) add('warn', 'link', `link:${l.folder}/${l.path}`, `/resources/${l.folder}/${l.path} is a link its machine cannot follow (${WHY[l.kind] ?? l.kind}): make it point inside the folder, or remove it.`);
126
130
  const order = ['ok', 'info', 'warn', 'bad'];
127
131
  const level = problems.reduce((worst, p) => (order.indexOf(p.level) > order.indexOf(worst) ? p.level : worst), 'ok');
128
132
  return { level, problems: problems.slice(0, PROBLEMS_MAX).map((p) => ({ ...p, text: p.text.slice(0, TEXT_MAX) })) };
129
133
  }
130
134
 
131
135
  export class Health {
132
- #vms; #conversations; #resources; #records = new Map(); #running = new Map(); #quiet = new Map(); #settleMs; #probeMs; #lookMs; #seen;
136
+ #vms; #conversations; #resources; #records = new Map(); #running = new Map(); #quiet = new Map(); #settleMs; #probeMs; #lookMs; #seen; #links;
133
137
  /* Set by the server: told each judgment (the chat of a conversation says what is wrong: talk.mjs). */
134
138
  judged = () => {};
135
139
  /* Set by the server: told a conversation is forgotten (what else is kept of its machine goes too). */
@@ -139,8 +143,9 @@ export class Health {
139
143
  again (a machine that has just started may still be mounting it).
140
144
  `lookMs`: how long the clock waits for one conversation's look. */
141
145
  /* `seen`: a store of the problems its owner marked seen ("Seen"), by conversation. */
142
- constructor({ vms, conversations, resources, settleMs = 5000, probeMs = PROBE_MS, lookMs = LOOK_MS, seen = null }) {
143
- this.#seen = seen;
146
+ /* `links`: the guard of links its folders cannot serve (linkguard.mjs), or null. */
147
+ constructor({ vms, conversations, resources, settleMs = 5000, probeMs = PROBE_MS, lookMs = LOOK_MS, seen = null, links = null }) {
148
+ this.#seen = seen; this.#links = links;
144
149
  this.#vms = vms; this.#conversations = conversations; this.#resources = resources; this.#settleMs = settleMs; this.#probeMs = probeMs; this.#lookMs = lookMs;
145
150
  }
146
151
 
@@ -217,13 +222,45 @@ export class Health {
217
222
  }
218
223
  }
219
224
  }
225
+ const links = await this.#lookLinks(name, asked);
220
226
  return this.#keep(name, {
221
227
  at, state, answering: true, memory: facts.memory, oomKills: facts.oomKills ?? 0, ...(facts.oomKinds ? { oomKinds: facts.oomKinds } : {}), disks: facts.disks,
222
- missing: loose(), ...(remounted.length ? { remounted } : {}), ...(failed.length ? { failed } : {}),
228
+ paths, missing: loose(), ...(remounted.length ? { remounted } : {}), ...(failed.length ? { failed } : {}), ...(links ? { links } : {}),
223
229
  /* What was put back stays said until a later look finds nothing to put back. */
224
230
  });
225
231
  }
226
232
 
233
+ /* Its folders' links, bounded (linkguard.mjs); never what holds up a look. */
234
+ async #lookLinks(name, force) {
235
+ if (!this.#links) return null;
236
+ return this.#links.check(name, { force }).catch(() => null);
237
+ }
238
+
239
+ /* "CHECK SHARED FOLDERS", asked by its owner: the same looks, now -- each
240
+ folder mounted and readable (put back when loose), each link the share
241
+ cannot serve -- and what was found or done, one item each. A machine
242
+ that does not run is not woken: only its folders' links are looked at. */
243
+ async checkFolders(name) {
244
+ const record = await this.check(name, { asked: true });
245
+ if (!record) return null;
246
+ let links = record.links;
247
+ if (!links && this.#links) links = await this.#links.check(name, { force: true }).catch(() => null);
248
+ const items = [];
249
+ if (record.state === 'Running' && record.answering === true) {
250
+ const failed = new Map((record.failed ?? []).map((f) => [f.path, f.why]));
251
+ for (const path of record.paths ?? []) {
252
+ if (failed.has(path)) items.push({ kind: 'mount-failed', path, why: failed.get(path) });
253
+ else if ((record.remounted ?? []).includes(path)) items.push({ kind: 'mount-remounted', path });
254
+ else if ((record.missing ?? []).includes(path)) items.push({ kind: 'mount-missing', path });
255
+ else items.push({ kind: 'mount-ok', path });
256
+ }
257
+ } else items.push({ kind: 'machine-off', state: record.state ?? null });
258
+ for (const l of links?.removed ?? []) items.push({ kind: 'link-removed', folder: l.folder, path: l.path, why: l.kind });
259
+ for (const l of links?.found ?? []) items.push({ kind: 'link-found', folder: l.folder, path: l.path, why: l.kind });
260
+ for (const c of links?.cut ?? []) items.push({ kind: 'links-cut', folder: c.folder, why: c.why });
261
+ return { at: record.at, items, health: this.of(name) };
262
+ }
263
+
227
264
  async #probe(name, paths) {
228
265
  /* Its output is the agent's: not more than a few pages of it are even taken in. */
229
266
  const r = await this.#vms.exec(name, ['sh', '-c', PROBE, PROBE_NAME, ...paths], { timeoutMs: this.#probeMs, maxBytes: PROBE_BYTES * 4 }).catch(() => ({ code: 1, stdout: '' }));
@@ -0,0 +1,191 @@
1
+ /* LINKS A SHARED FOLDER CANNOT SERVE, found on the server and, where it is
2
+ * safe, taken out.
3
+ *
4
+ * WHY. A folder is shown in a machine by the VM manager's own file server
5
+ * (multipass: sshfs in the machine, its SFTP server on this host). That
6
+ * server, not the machine, decides; root in the machine counts for nothing.
7
+ * It judges a link BY ITS TARGET: the target must lie in the folder and
8
+ * belong to the account the folder is mapped from (`-u`/`-g` of the mount).
9
+ * A link whose target is missing, loops, or lies elsewhere on this host has
10
+ * no such owner: reading it, renaming it and often removing it are refused
11
+ * ("Permission denied"), and what it says of it is nonsense ("Value too
12
+ * large"). Seen 07-11: app/node_modules made a link by a worktree, then
13
+ * committed, in a read-write folder its machine could not repair.
14
+ *
15
+ * WHAT IS DONE, here, where the folder is plain files:
16
+ * - a link named node_modules that the share cannot serve: removed (the
17
+ * link, never what it points to); its tool is told it may install its
18
+ * dependencies again;
19
+ * - a link that loops, or points nowhere outside the folder: removed;
20
+ * - any other (an absolute target, one outside the folder, one inside it
21
+ * not there yet): said, and left.
22
+ *
23
+ * BOUNDED: a few levels deep, so many entries, so long; .git and real
24
+ * node_modules folders are not walked. A folder looked at recently is not
25
+ * looked at again unless asked (`force`).
26
+ */
27
+ import { opendir, lstat, readlink, realpath, unlink } from 'node:fs/promises';
28
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
29
+
30
+ export const CAPS = Object.freeze({ depth: 6, entries: 20_000, ms: 2_000, links: 50 });
31
+ /* A folder is looked at again by the clock this often at most. */
32
+ export const EVERY_MS = 10 * 60_000;
33
+ /* A removal is said to a conversation once a day at most, should the link come back. */
34
+ export const SAY_AGAIN_MS = 24 * 60 * 60_000;
35
+ const NOT_WALKED = new Set(['.git', 'node_modules']);
36
+ export const KINDS = ['loop', 'dangling', 'absolute', 'outside'];
37
+
38
+ const inside = (root, path) => path === root || path.startsWith(`${root}${sep}`);
39
+
40
+ /* What is wrong with the link at `full` (under the real path `root`), or null. */
41
+ export async function judgeLink(root, full) {
42
+ let target;
43
+ try { target = await readlink(full); } catch { return null; }
44
+ let real;
45
+ try { real = await realpath(full); } catch (error) {
46
+ if (error.code === 'ELOOP') return { kind: 'loop', target };
47
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
48
+ /* Where it would point, as written: outside the folder, it points nowhere the share serves. */
49
+ const aim = isAbsolute(target) ? target : resolve(dirname(full), target);
50
+ return { kind: 'dangling', target, escapes: isAbsolute(target) || !inside(root, aim) };
51
+ }
52
+ return { kind: 'outside', target };
53
+ }
54
+ /* An absolute target is a path of THIS host: in the machine it names something else, or nothing. */
55
+ if (isAbsolute(target)) return { kind: 'absolute', target };
56
+ if (!inside(root, real)) return { kind: 'outside', target };
57
+ return null;
58
+ }
59
+
60
+ /* Taken out without asking: a node_modules link, a loop, a link to nowhere outside the folder. */
61
+ export function removable(link) {
62
+ const base = link.path.split('/').pop();
63
+ return base === 'node_modules' || link.kind === 'loop' || (link.kind === 'dangling' && link.escapes === true);
64
+ }
65
+
66
+ /* The links of one folder the share cannot serve: { links: [{ path, kind, target }], cut }.
67
+ `cut`: why the look stopped early ('depth', 'entries', 'time', 'links'), or null. */
68
+ export async function scanFolder(folder, caps = CAPS, now = () => Date.now()) {
69
+ const root = await realpath(folder);
70
+ const until = now() + caps.ms;
71
+ const links = []; let entries = 0; let cut = null; let deeper = false;
72
+ const stack = [{ dir: root, depth: 0 }];
73
+ while (stack.length > 0 && !cut) {
74
+ const { dir, depth } = stack.pop();
75
+ let handle;
76
+ /* A folder swapped for a link since it was listed is not followed out. */
77
+ try { if (dir !== root && (await realpath(dir)) !== dir) continue; handle = await opendir(dir); } catch { continue; }
78
+ try {
79
+ for (let entry = await handle.read(); entry !== null; entry = await handle.read()) {
80
+ if (++entries > caps.entries) { cut = 'entries'; break; }
81
+ if (now() > until) { cut = 'time'; break; }
82
+ const full = join(dir, entry.name);
83
+ if (entry.isSymbolicLink()) {
84
+ const wrong = await judgeLink(root, full);
85
+ if (!wrong) continue;
86
+ if (links.length >= caps.links) { cut = 'links'; break; }
87
+ links.push({ path: relative(root, full).split(sep).join('/'), ...wrong });
88
+ } else if (entry.isDirectory() && !NOT_WALKED.has(entry.name)) {
89
+ if (depth + 1 < caps.depth) stack.push({ dir: full, depth: depth + 1 });
90
+ else deeper = true;
91
+ }
92
+ }
93
+ } finally { await handle.close().catch(() => {}); }
94
+ }
95
+ return { links, cut: cut ?? (deeper ? 'depth' : null) };
96
+ }
97
+
98
+ /* Removes the link `path` of `folder`: '' when done, or why not. Only a link,
99
+ and only where its folder is still the one walked (a folder on the way
100
+ swapped for a link meanwhile is not followed). */
101
+ export async function removeLink(folder, path) {
102
+ const root = await realpath(folder);
103
+ const full = join(root, ...path.split('/'));
104
+ if (!inside(root, full) || full === root) return 'not in the folder';
105
+ try {
106
+ if ((await realpath(dirname(full))) !== dirname(full)) return 'its folder moved';
107
+ if (!(await lstat(full)).isSymbolicLink()) return 'not a link any more';
108
+ await unlink(full);
109
+ return '';
110
+ } catch (error) { return error.code === 'ENOENT' ? 'already gone' : error.message; }
111
+ }
112
+
113
+ /* THE GUARD, for each conversation's attached folders.
114
+ `resources`: of(conversation) [{ name }], folder(name), attachedTo(name) [conversation]
115
+ `removed(conversation, { folder, path, kind, target })`: told once per removal, to each
116
+ conversation the folder is attached to (its chat, its tool: main.mjs). */
117
+ export class LinkGuard {
118
+ #resources; #caps; #everyMs; #now; #last = new Map(); #going = new Map(); #said = new Map();
119
+ removed = () => {};
120
+ constructor({ resources, caps = CAPS, everyMs = EVERY_MS, now = () => Date.now() }) {
121
+ this.#resources = resources; this.#caps = caps; this.#everyMs = everyMs; this.#now = now;
122
+ }
123
+
124
+ /* Looks at the folders of `conversation`: { found, removed, cut }, each with its folder's name. */
125
+ async check(conversation, { force = false } = {}) {
126
+ const out = { found: [], removed: [], cut: [] };
127
+ for (const { name } of this.#resources.of(conversation)) {
128
+ /* A mailbox is written by this server alone. */
129
+ if (this.#resources.get?.(name)?.type === 'mailbox') continue;
130
+ const r = await this.#folder(name, force).catch(() => null);
131
+ if (!r) continue;
132
+ out.found.push(...r.found.map((l) => ({ folder: name, ...l })));
133
+ out.removed.push(...r.removed.map((l) => ({ folder: name, ...l })));
134
+ if (r.cut) out.cut.push({ folder: name, why: r.cut });
135
+ }
136
+ return out;
137
+ }
138
+
139
+ /* One folder, one look at a time (two conversations may share it). */
140
+ #folder(name, force) {
141
+ const last = this.#last.get(name);
142
+ if (!force && last && this.#now() - last.at < this.#everyMs) return Promise.resolve({ ...last.result, removed: [] });
143
+ const going = this.#going.get(name);
144
+ if (going) return going;
145
+ const look = this.#look(name).finally(() => this.#going.delete(name));
146
+ this.#going.set(name, look);
147
+ return look;
148
+ }
149
+
150
+ async #look(name) {
151
+ const folder = this.#resources.folder(name);
152
+ const { links, cut } = await scanFolder(folder, this.#caps, this.#now);
153
+ const found = []; const removed = [];
154
+ for (const link of links) {
155
+ if (removable(link) && (await removeLink(folder, link.path)) === '') removed.push(link);
156
+ else found.push(link);
157
+ }
158
+ const result = { found, removed, cut };
159
+ this.#last.set(name, { at: this.#now(), result: { found, removed: [], cut } });
160
+ for (const link of removed) this.#tell(name, link);
161
+ return result;
162
+ }
163
+
164
+ #tell(name, link) {
165
+ const conversations = this.#resources.attachedTo?.(name) ?? [];
166
+ for (const conversation of conversations) {
167
+ const key = `${conversation}\0${name}\0${link.path}`;
168
+ if (this.#now() - (this.#said.get(key) ?? -Infinity) < SAY_AGAIN_MS) continue;
169
+ this.#said.set(key, this.#now());
170
+ try { this.removed(conversation, { folder: name, ...link }); } catch { /* told, nothing more */ }
171
+ }
172
+ }
173
+
174
+ forget(name) { this.#last.delete(name); }
175
+ }
176
+
177
+ /* What it says, in French, in the conversation's chat (one line). */
178
+ export function removedLine({ folder, path }) {
179
+ const where = path.includes('/') ? ` (${path})` : '';
180
+ return path.split('/').pop() === 'node_modules'
181
+ ? `Un lien node_modules cassé dans ${folder}${where} a été retiré ; ses dépendances sont à réinstaller.`
182
+ : `Un lien cassé dans ${folder} (${path}) a été retiré : il ne menait nulle part.`;
183
+ }
184
+ /* What its tool is told, when the link was a node_modules (in English, as its other lines). */
185
+ export function toolLine({ folder, path }) {
186
+ const dir = path.split('/').slice(0, -1).join('/');
187
+ const at = `/resources/${folder}${dir ? `/${dir}` : ''}`;
188
+ return `Clavarium: ${at}/node_modules was a link your machine could not use; it was removed on the server (what it pointed to was left as it was). You may install the dependencies again now: run npm ci (or npm install) in ${at}. If your rules forbid npm install, ask the operator to allow it.`;
189
+ }
190
+ /* Why a link is said, in a few words. */
191
+ export const WHY = Object.freeze({ loop: 'it loops', dangling: 'its target is missing', absolute: 'its target is an absolute path of the server', outside: 'its target is outside the folder' });
package/server/main.mjs CHANGED
@@ -59,6 +59,7 @@ import { GitKeeper } from './gitkeeper.mjs';
59
59
  import { GitRunner } from './gitrunner.mjs';
60
60
  import { Shelf } from './shelf.mjs';
61
61
  import { Health } from './health.mjs';
62
+ import { LinkGuard, removedLine, toolLine } from './linkguard.mjs';
62
63
  import { Setup } from './setup.mjs';
63
64
  import { Capacity } from './capacity.mjs';
64
65
  import { packageFile, PARLEY } from './parley.mjs';
@@ -575,7 +576,17 @@ export async function startHome({ port, host, dataDir, mntDir = dataDir, gitRese
575
576
  }
576
577
  }, 15_000); restartTimer.unref?.();
577
578
  /* The health of each machine, and folders that came loose put back: see health.mjs. */
578
- const health = new Health({ vms, conversations: all, resources: folders, seen: new Store(`${dataDir}/health-seen.json`), ...(healthSettleMs === undefined ? {} : { settleMs: healthSettleMs }) });
579
+ /* Links its folders hold that the share cannot serve: removed where safe, said once (linkguard.mjs). */
580
+ const linkGuard = typeof folders.attachedTo === 'function' ? new LinkGuard({ resources: folders }) : null;
581
+ if (linkGuard) linkGuard.removed = (name, link) => {
582
+ try { talk.event(name, { kind: 'machine-ok', text: removedLine(link), topic: 'Machine', brief: 'Shared folder' }); } catch { /* its chat is off */ }
583
+ if (link.path.split('/').pop() !== 'node_modules') return;
584
+ /* Its tool told it may install them again: through its docket where its chat is on, else typed to its agent. */
585
+ const line = toolLine(link);
586
+ try { if (dockets.on(name)) { dockets.post(name, { kind: 'shared-folder', text: line, data: { folder: link.folder, path: link.path } }); return; } } catch { /* typed instead */ }
587
+ if (all.list().find((c) => c.name === name)?.ai === true) void wakeups.say(name, line).catch(() => {});
588
+ };
589
+ const health = new Health({ vms, conversations: all, resources: folders, links: linkGuard, seen: new Store(`${dataDir}/health-seen.json`), ...(healthSettleMs === undefined ? {} : { settleMs: healthSettleMs }) });
579
590
  const stopHealth = health.start(healthEveryMs);
580
591
  /* What is wrong with its machine is said in its chat too, where it happened. */
581
592
  /* And what its owner chose that it does on top (healthreact.mjs): its agent told, or a command run. */
@@ -589,6 +600,8 @@ export async function startHome({ port, host, dataDir, mntDir = dataDir, gitRese
589
600
  const problemsSaid = new Map();
590
601
  health.judged = (name, record) => {
591
602
  talk.machine(name, record); void reacting.judged(name, record).catch(() => {});
603
+ /* A folder that came loose and was put back: said once, in its chat (the look that put it back). */
604
+ for (const path of record?.remounted ?? []) { try { talk.event(name, { kind: 'machine-ok', text: `Le dossier ${path} s'était détaché de la machine : il a été remonté.`, topic: 'Machine', brief: 'Shared folder' }); } catch { /* its chat is off */ } }
592
605
  try { healthDocket.judged(name, record); } catch { /* its docket: nothing more */ }
593
606
  const now = (record?.problems ?? []).filter((x) => x.level === 'bad' || (x.level === 'warn' && ['memory', 'disk'].includes(x.kind)));
594
607
  const before = problemsSaid.get(name) ?? new Set();
@@ -140,6 +140,12 @@ export class Resources {
140
140
  }
141
141
  get(name) { return this.#store.get(name); }
142
142
 
143
+ /* The conversations a folder is attached to. */
144
+ attachedTo(name) {
145
+ const resource = this.#store.get(name);
146
+ return resource ? Object.keys(this.#attachments(resource)) : [];
147
+ }
148
+
143
149
  /* What one conversation has attached: [{ name, access }]. */
144
150
  of(conversation) {
145
151
  return this.#store.all().map((r) => ({ name: r.name, access: this.#attachments(r)[conversation] })).filter((r) => r.access !== undefined);
package/server/rooms.mjs CHANGED
@@ -100,7 +100,7 @@ export class Rooms {
100
100
  in its row of the list and on its bar, « Hugo réfléchit… » in its thread. */
101
101
  #thinkingOf(id, members) { return [...(this.#thinking.get(id) ?? new Map()).entries()].filter(([, v]) => Date.now() - v.at < THINKING_MS).map(([mid]) => members.find((m) => m.id === mid)?.name).filter(Boolean); }
102
102
  #doingOf(id, members) { return [...(this.#thinking.get(id) ?? new Map()).entries()].filter(([, v]) => v.text && Date.now() - v.at < THINKING_MS).map(([mid, v]) => ({ name: members.find((m) => m.id === mid)?.name, text: v.text })).filter((x) => x.name); }
103
- list() { return this.#all().map((r) => { const members = this.#shown(r); return { id: r.id, name: r.name, host: r.host, members, at: r.at, unread: this.#unread(r), last: this.#last(r), thinking: this.#thinkingOf(r.id, members) }; }); }
103
+ list() { return this.#all().map((r) => { const members = this.#shown(r); return { id: r.id, name: r.name, host: r.host, members, at: r.at, unread: this.#unread(r), last: this.#last(r), thinking: this.#thinkingOf(r.id, members), ...(r.quiet ? { quiet: true } : {}) }; }); }
104
104
  /* ITS AIs, as conversations (06-11): the names of the conversations of its AI members that are there now --
105
105
  never a box, never the person, never one taken out of the room. What « give to all its AIs » gives to. */
106
106
  ais(id) { return this.#shown(this.#find(id)).filter((m) => m.kind === 'ai').map((m) => m.ref); }
@@ -139,10 +139,11 @@ export class Rooms {
139
139
  if (new Set(names).size !== names.length) throw new Refusal(409, 'Two members of a room cannot have the same name: rename one first.');
140
140
  if (room.members.length > MEMBERS_MAX) throw new Refusal(409, `${MEMBERS_MAX} members at most.`);
141
141
  }
142
- /* Its name, its host, members added or taken away. */
143
- change(id, { name, host, add = {}, remove = [] } = {}) {
142
+ /* Its name, its host, members added or taken away; `quiet` (07-11, the tidy-up): muted in the list, or not. */
143
+ change(id, { name, host, add = {}, remove = [], quiet } = {}) {
144
144
  const list = this.#all(); const room = list.find((r) => r.id === id);
145
145
  if (!room) throw new Refusal(404, 'No such room.');
146
+ if (quiet !== undefined) { if (quiet === true) room.quiet = true; else delete room.quiet; }
146
147
  if (name !== undefined) {
147
148
  const n = nameOf(name); if (!n) throw new Refusal(400, 'Give the room a name.');
148
149
  if (list.some((r) => r.id !== id && r.name.toLowerCase() === n.toLowerCase())) throw new Refusal(409, 'A room has that name already.');
@@ -13,6 +13,13 @@ export const SETTINGS_WORDS_FR = {
13
13
  'set-ch-s-usage': 'Usage', 'set-ch-s-backup': 'Sauvegarde', 'set-ch-s-advanced': 'Avancé',
14
14
  'set-check-updates': 'Chercher une mise à jour', 'set-activity': 'Activité', 'set-restart-dots': 'Redémarrer…', 'set-terminal': 'Terminal',
15
15
  'set-search': 'Chercher', 'set-why': 'Pourquoi',
16
+ /* Diagnostics › « Vérifier les dossiers partagés » (health.mjs, linkguard.mjs). */
17
+ 'set-check-folders': 'Vérifier les dossiers partagés', 'set-looking': 'Vérification…',
18
+ 'set-fold-ok': '{path} : monté, lisible', 'set-fold-remounted': '{path} : détaché, remonté', 'set-fold-missing': '{path} : inaccessible',
19
+ 'set-fold-failed': '{path} : inaccessible, pas remonté', 'set-fold-link-removed': '{path} : lien cassé retiré ({why})',
20
+ 'set-fold-link-found': '{path} : lien inutilisable ({why})', 'set-fold-cut': '{path} : trop gros, vérifié en partie',
21
+ 'set-fold-off': 'Machine arrêtée : liens seuls vérifiés', 'set-fold-none': 'Aucun dossier partagé',
22
+ 'set-link-loop': 'boucle', 'set-link-dangling': 'cible absente', 'set-link-absolute': 'cible en chemin absolu', 'set-link-outside': 'cible hors du dossier',
16
23
  'set-memory': 'Mémoire', 'set-disk': 'Disque', 'set-cpu': 'Processeur',
17
24
  'set-free-of': '{free} libres sur {total}', 'set-left-of': '{free} restants sur {total}', 'set-highest': 'au plus {n} % en 1 h',
18
25
  'set-core': '1 cœur', 'set-cores': '{n} cœurs',
@@ -33,7 +40,7 @@ export const SETTINGS_WORDS_FR = {
33
40
  'set-dict-backup': 'Secours en service', 'set-dict-expired': 'Clé expirée', 'set-dict-with-backup': 'avec secours', 'set-dict-server': 'réglée sur le serveur',
34
41
  'set-attached': '{n} joints', 'set-secret': '1 secret', 'set-secrets': '{n} secrets', 'set-offline': 'hors ligne',
35
42
  'set-device': '1 appareil', 'set-devices': '{n} appareils', 'set-no-device': 'Aucun appareil', 'set-chat': 'Chat',
36
- 'set-wakeups-on': '{on} sur {n} actifs', 'set-asleep': 'Endormie', 'set-not-looked': 'Pas encore regardée',
43
+ 'set-wakeups-on': '{on} sur {n} actifs', 'set-asleep': 'En veille', 'set-not-looked': 'Pas encore regardée',
37
44
  'set-running-well': 'En marche · rien d\'anormal', 'set-conv-advanced': 'Archiver · remettre à neuf · supprimer',
38
45
  /* New asset, one box (05-11, step 3): one verb per gesture -- Créer, Attacher, Terminé. */
39
46
  'set-na-new': 'Nouvel asset', 'set-na-attach': 'Attacher', 'set-na-there': 'Déjà là', 'set-na-or-new': 'ou un nouveau',
@@ -109,11 +116,15 @@ export const SETTINGS_WORDS_FR = {
109
116
  'set-list-not-filed': 'Sans groupe', 'set-list-asks': 'vous demande', 'set-list-waits': 'vous attend',
110
117
  'set-list-asks-for': 'le répondeur vous demande', 'set-list-answered': 'a répondu à votre place',
111
118
  'set-list-wakeup': '1 réveil', 'set-list-wakeups': '{n} réveils', 'set-list-hidden': 'cachée',
112
- 'set-list-asleep': 'endormie, se réveille à l\'ouverture', 'set-list-starting': 'démarre', 'set-list-restarting': 'redémarre',
119
+ 'set-list-asleep': 'en veille', 'set-list-starting': 'démarre', 'set-list-restarting': 'redémarre',
113
120
  'set-list-moving': 'déplace son travail', 'set-list-failed': 'en échec', 'set-list-you': 'Vous : {text}',
114
121
  'set-list-fewer': 'En montrer moins', 'set-list-more': 'En montrer {n} de plus',
115
122
  'set-list-hide-hidden': 'Masquer les {n} cachées', 'set-list-n-hidden': '{n} cachées',
116
123
  'set-list-tip': 'Rangez-les en groupes : Réglages, Groupe.',
124
+ 'set-idle-h': 'rien depuis {n} h', 'set-idle-d': 'rien depuis {n} j', 'set-idle-w': 'rien depuis {n} sem.', 'set-idle-mo': 'rien depuis {n} mois',
125
+ 'set-tidy-open': 'Faire le ménage ({n})', 'set-tidy-title': 'Mettre en veille ?', 'set-tidy-hint': 'Rien depuis 12 h ou plus.',
126
+ 'set-tidy-go': 'Mettre en veille ({n})', 'set-close': 'Fermer',
127
+ 'set-asleep-page': 'En veille. Elle reprend quand vous la réveillez.', 'set-wake-it': 'Réveiller',
117
128
  'set-as-k-git': 'Dépôts git', 'set-as-k-folder': 'Dossiers', 'set-as-k-mailbox': 'Boîtes mail', 'set-as-k-agenda': 'Agendas',
118
129
  'set-as-k-contacts': 'Contacts', 'set-as-k-secret': 'Secrets', 'set-as-k-list': 'Classeurs', 'set-as-k-acces': 'Accès',
119
130
  'set-as-kind-folder': 'dossier', 'set-as-kind-git': 'dépôt', 'set-as-kind-mailbox': 'boîte mail',
@@ -134,12 +145,14 @@ export const SETTINGS_WORDS_FR = {
134
145
  'set-files-read-only': 'Lecture seule : rien ne peut y être changé.',
135
146
  'set-this-conv': 'Cette conversation · {name}', 'set-ask-first': 'Demander avant d\'agir',
136
147
  'set-online': 'En ligne', 'set-offline-btn': 'Hors ligne', 'set-sleep': 'Endormir',
137
- 'set-room-name': 'Nom', 'set-room-host': 'Accueille le salon', 'set-room-host-what': 'répond, ou passe le message',
138
- 'set-room-host-pick': 'Choisissez ci-dessous : « Accueille »', 'set-room-welcomes': 'Accueille', 'set-room-ais': 'Ses IA',
139
- 'set-room-in': '{n} dans le salon', 'set-room-find': 'Chercher une conversation', 'set-room-boxes': 'Boîtiers vocaux',
148
+ 'set-room-name': 'Nom', 'set-room-welcomes': 'Accueille', 'set-room-ais': 'Ses IA',
149
+ 'set-room-find': 'Chercher une conversation', 'set-room-boxes': 'Boîtiers vocaux',
140
150
  'set-room-remove': 'Supprimer le salon', 'set-room-remove-what': 'Le fil part · les IA et boîtiers restent',
141
151
  'set-room-remove-dots': 'Supprimer…', 'set-room-remove-yes': 'Supprimer {name}', 'set-room-keep': 'Garder',
142
- 'set-room-cancel': 'Annuler', 'set-room-save': 'Enregistrer', 'set-room-create': 'Créer', 'set-room-new': 'Nouveau salon',
152
+ 'set-room-create': 'Créer', 'set-room-new': 'Nouveau salon',
153
+ /* A room's settings as every settings page (07-11): each change saved at once, said a moment in the bar. */
154
+ 'set-room-saved': 'Enregistré', 'set-room-settings-of': '{name} · Réglages', 'set-room-of': '{n} sur {t}',
155
+ 'set-room-host-how': 'Touchez une autre IA du salon pour la déplacer',
143
156
  'set-room-people': 'Personnes', 'set-room-ais-of': 'IA', 'set-room-boxes-of': 'Boîtiers', 'set-room-host-mark': 'accueille',
144
157
  'set-room-n-person': '1 personne', 'set-room-n-people': '{n} personnes', 'set-room-n-ai': '1 IA', 'set-room-n-ais': '{n} IA',
145
158
  'set-room-n-box': '1 boîtier', 'set-room-n-boxes': '{n} boîtiers',
package/server/talk.mjs CHANGED
@@ -653,10 +653,11 @@ export class Talk {
653
653
  tone: fresh.some((p) => p.level === 'bad') ? 'bad' : 'warn',
654
654
  topic: 'Machine',
655
655
  /* Its name on the one line a problem is in the simple view (Parley, 03-11). */
656
- brief: fresh.some((p) => p.kind === 'memory' || /^oom:|^memory/.test(p.key ?? '')) ? 'Out of memory' : fresh.every((p) => p.kind === 'disk') ? 'Disk almost full' : 'Machine problem',
656
+ brief: fresh.some((p) => p.kind === 'memory' || /^oom:|^memory/.test(p.key ?? '')) ? 'Out of memory' : fresh.every((p) => p.kind === 'disk') ? 'Disk almost full' : fresh.every((p) => p.kind === 'link') ? 'Shared folder' : 'Machine problem',
657
657
  /* What can be done: a disk short of room is looked at (what takes it) or made larger --
658
658
  a restart frees nothing; otherwise more memory where memory is short, a restart, its terminal. */
659
- actions: [...(fresh.every((p) => p.kind === 'disk') ? ['room', 'disk'] : [...(fresh.some((p) => p.kind === 'memory') ? ['memory'] : []), 'restart', 'terminal']), 'seen'],
659
+ /* A link in a folder (linkguard.mjs) is the folder's business: a restart changes nothing. */
660
+ actions: [...(fresh.every((p) => p.kind === 'link') ? [] : fresh.every((p) => p.kind === 'disk') ? ['room', 'disk'] : [...(fresh.some((p) => p.kind === 'memory') ? ['memory'] : []), 'restart', 'terminal']), 'seen'],
660
661
  });
661
662
  }
662
663