@elu-labs/elu 0.0.0-stage → 0.1.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.
Files changed (102) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSES/Apache-2.0.txt +202 -0
  3. package/README.md +327 -2
  4. package/SECURITY.md +333 -0
  5. package/THIRD_PARTY_NOTICES.md +62 -0
  6. package/bin/elu-code.mjs +209 -0
  7. package/bin/elu.cjs +10 -0
  8. package/bin/start.cjs +6 -0
  9. package/docs/peer-network.md +257 -0
  10. package/docs/permissions.md +285 -0
  11. package/docs/vendors/antigravity.md +131 -0
  12. package/docs/vendors/claude.md +124 -0
  13. package/docs/vendors/codex.md +64 -0
  14. package/docs/vendors/grok.md +151 -0
  15. package/lib/acp-client.mjs +100 -0
  16. package/lib/adapters/agy.mjs +283 -0
  17. package/lib/adapters/claude.mjs +311 -0
  18. package/lib/adapters/codex.mjs +137 -0
  19. package/lib/adapters/grok.mjs +384 -0
  20. package/lib/adapters/index.mjs +13 -0
  21. package/lib/agents.mjs +77 -0
  22. package/lib/agy-hook.mjs +53 -0
  23. package/lib/approval-server.mjs +85 -0
  24. package/lib/approvals.mjs +128 -0
  25. package/lib/awake.mjs +45 -0
  26. package/lib/board.mjs +261 -0
  27. package/lib/bot-models.mjs +71 -0
  28. package/lib/bot-schedules.mjs +291 -0
  29. package/lib/bots.mjs +361 -0
  30. package/lib/changes.mjs +168 -0
  31. package/lib/checks.mjs +102 -0
  32. package/lib/conductor.mjs +337 -0
  33. package/lib/config.mjs +147 -0
  34. package/lib/conformance.mjs +109 -0
  35. package/lib/default-bots.mjs +150 -0
  36. package/lib/detect.mjs +108 -0
  37. package/lib/diff.mjs +217 -0
  38. package/lib/editor.mjs +165 -0
  39. package/lib/format.mjs +51 -0
  40. package/lib/git.mjs +173 -0
  41. package/lib/init.mjs +129 -0
  42. package/lib/install.mjs +130 -0
  43. package/lib/kanban.mjs +180 -0
  44. package/lib/mcp.mjs +79 -0
  45. package/lib/memory.mjs +35 -0
  46. package/lib/models.mjs +144 -0
  47. package/lib/modes.mjs +95 -0
  48. package/lib/never-list.mjs +110 -0
  49. package/lib/never-shims.mjs +311 -0
  50. package/lib/paste.mjs +56 -0
  51. package/lib/peer/a2a-http.mjs +166 -0
  52. package/lib/peer/a2a.mjs +357 -0
  53. package/lib/peer/asks.mjs +329 -0
  54. package/lib/peer/canonical.mjs +72 -0
  55. package/lib/peer/cli.mjs +154 -0
  56. package/lib/peer/daemon.mjs +525 -0
  57. package/lib/peer/identity.mjs +81 -0
  58. package/lib/peer/index.mjs +291 -0
  59. package/lib/peer/intake.mjs +207 -0
  60. package/lib/peer/invite.mjs +76 -0
  61. package/lib/peer/paths.mjs +7 -0
  62. package/lib/peer/peerdata.mjs +21 -0
  63. package/lib/peer/pictures.mjs +295 -0
  64. package/lib/peer/plans.mjs +182 -0
  65. package/lib/peer/relay.mjs +31 -0
  66. package/lib/peer/replies.mjs +128 -0
  67. package/lib/peer/roster.mjs +256 -0
  68. package/lib/peer/rpc.mjs +88 -0
  69. package/lib/peer/secrets.mjs +88 -0
  70. package/lib/peer/settings.mjs +45 -0
  71. package/lib/peer/shares.mjs +120 -0
  72. package/lib/peer/status.mjs +84 -0
  73. package/lib/peer/taskstore.mjs +108 -0
  74. package/lib/peer/transport.mjs +120 -0
  75. package/lib/pictures.mjs +104 -0
  76. package/lib/plan.mjs +238 -0
  77. package/lib/policy.mjs +98 -0
  78. package/lib/process.mjs +92 -0
  79. package/lib/project-settings.mjs +55 -0
  80. package/lib/project-trust.mjs +230 -0
  81. package/lib/projects.mjs +668 -0
  82. package/lib/publish.mjs +105 -0
  83. package/lib/runner.mjs +874 -0
  84. package/lib/sandbox.mjs +102 -0
  85. package/lib/schedules.mjs +270 -0
  86. package/lib/selection.mjs +202 -0
  87. package/lib/shell.mjs +101 -0
  88. package/lib/state.mjs +247 -0
  89. package/lib/terminal-bots.mjs +239 -0
  90. package/lib/terminal-commands.mjs +165 -0
  91. package/lib/terminal-plan.mjs +37 -0
  92. package/lib/terminal-visuals.mjs +268 -0
  93. package/lib/terminal.mjs +3159 -0
  94. package/lib/unit-file.mjs +17 -0
  95. package/lib/usage.mjs +249 -0
  96. package/lib/util.mjs +86 -0
  97. package/lib/vendor-check.mjs +96 -0
  98. package/lib/vendors.mjs +191 -0
  99. package/lib/workspace.mjs +168 -0
  100. package/lib/yaml.mjs +624 -0
  101. package/npm-shrinkwrap.json +827 -0
  102. package/package.json +70 -4
@@ -0,0 +1,230 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { atomic, hash, isObject, mkdir, noSymlinks, personalDirectory, refuse } from './util.mjs';
5
+
6
+ // A project's settings and instructions come with its folder: from Git, from a teammate, by
7
+ // hand, or from a model that edited them. They count as the owner's only when the owner
8
+ // approved that exact content on this machine. What was approved, and what was turned down,
9
+ // is recorded here by hash in ELU Code's own folder, which the launch rules keep a session's
10
+ // file tools out of: never in the project, which a session edits. Anything else is not used
11
+ // and is never sent to a model. The text he approved last is kept with the record and stays
12
+ // in force until he approves a change, so changing, removing or replacing a file never drops
13
+ // his settings back to the defaults by itself.
14
+ export const TRUST_FILES = Object.freeze(['config.yaml', 'instructions.md', 'context.md']);
15
+ // How many answers are kept for one file, so moving between branches does not ask again.
16
+ const KEEP = 32;
17
+ // A file over this size is never used and never read: it cannot be reviewed on a screen. So
18
+ // the text approved last always fits in the record.
19
+ export const TEXT_LIMIT = 100 * 1024;
20
+ // What the record holds for "the file is not there": approving it is the owner's yes to
21
+ // going without the file. It counts only while it is his latest approval: once he approves
22
+ // a text again, a file that goes missing is asked about again.
23
+ const GONE = '0'.repeat(64);
24
+ const wentWithout = row => row.approved.at(-1) === GONE;
25
+ // Tests keep ELU Code's folder in a temporary place. One that reached the owner's own would
26
+ // write approvals for its throwaway projects into his record, so it is refused.
27
+ function storeFile() {
28
+ if (process.env.NODE_TEST_CONTEXT && !process.env.XDG_CONFIG_HOME) refuse('A test must set XDG_CONFIG_HOME before it uses a project: the record of approved project files is never a test\'s to write.');
29
+ return path.join(personalDirectory(), 'project-trust.json');
30
+ }
31
+ // A project is known by its real path: the same folder reached through a link is the same
32
+ // project, and a copy or a moved folder is a new one.
33
+ const keyOf = root => { try { return fs.realpathSync(root); } catch { return path.resolve(root); } };
34
+
35
+ // The file as it is now, read once: the text a session would use and the hash of its bytes.
36
+ // null when there is none. One over the limit is not read: it is known by its size and time,
37
+ // so it is asked about once and not again until it changes. A symbolic link on the way is refused.
38
+ const tooLarge = (size, time) => ({ large: true, size, text: null, hash: hash(`too large\0${size}\0${time}`) });
39
+ function current(root, name) {
40
+ const file = path.join(root, '.elu-code', name); noSymlinks(file);
41
+ let bytes;
42
+ try {
43
+ const stat = fs.statSync(file); if (!stat.isFile()) return null;
44
+ if (stat.size > TEXT_LIMIT) return tooLarge(stat.size, stat.mtimeMs);
45
+ bytes = fs.readFileSync(file);
46
+ } catch (error) { if (['ENOENT', 'ENOTDIR'].includes(error.code)) return null; throw error; }
47
+ return bytes.length > TEXT_LIMIT ? tooLarge(bytes.length, 0) : { text: bytes.toString('utf8'), hash: hash(bytes) };
48
+ }
49
+
50
+ // The record is read again only when its file changed. One that cannot be read approves nothing.
51
+ let cache = { sig: null, store: null };
52
+ function readStore() {
53
+ const file = storeFile(); noSymlinks(file);
54
+ let stat; try { stat = fs.statSync(file); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
55
+ const sig = `${file}\0${stat.ino}\0${stat.size}\0${stat.mtimeMs}`;
56
+ if (cache.sig === sig) return cache.store;
57
+ let store; try { store = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { store = null; }
58
+ if (!isObject(store) || !isObject(store.projects)) store = { version: 1, projects: {} };
59
+ cache = { sig, store }; return store;
60
+ }
61
+ const hashes = value => Array.isArray(value) ? value.filter(item => typeof item === 'string') : [];
62
+ function entryOf(store, key, name) {
63
+ const project = Object.hasOwn(store.projects, key) && isObject(store.projects[key]) ? store.projects[key] : {};
64
+ const row = Object.hasOwn(project, name) && isObject(project[name]) ? project[name] : {};
65
+ const text = typeof row.text === 'string' ? row.text : null;
66
+ return { approved: hashes(row.approved), declined: hashes(row.declined), text, at: text !== null && typeof row.at === 'string' ? row.at : null };
67
+ }
68
+ // use: the text approved now is the one in force from here on, with when it was approved.
69
+ // Approving that the file is gone ends that. Turning content down changes neither.
70
+ function answer(store, root, name, { hash: value, text = null }, use) {
71
+ const key = keyOf(root), row = entryOf(store, key, name), without = list => list.filter(item => item !== value);
72
+ row.approved = without(row.approved); row.declined = without(row.declined);
73
+ (use ? row.approved : row.declined).push(value);
74
+ row.approved = row.approved.slice(-KEEP); row.declined = row.declined.slice(-KEEP);
75
+ if (use) { row.text = value !== GONE && typeof text === 'string' && Buffer.byteLength(text) <= TEXT_LIMIT ? text : null; row.at = row.text === null ? null : new Date().toISOString(); }
76
+ if (!Object.hasOwn(store.projects, key) || !isObject(store.projects[key])) store.projects[key] = {};
77
+ store.projects[key][name] = row.text === null ? { approved: row.approved, declined: row.declined } : row;
78
+ }
79
+ function listedProjects() {
80
+ try {
81
+ const file = path.join(personalDirectory(), 'projects.json'); noSymlinks(file);
82
+ const rows = JSON.parse(fs.readFileSync(file, 'utf8'));
83
+ return Array.isArray(rows) ? rows.map(row => row?.root).filter(root => typeof root === 'string') : [];
84
+ } catch { return []; }
85
+ }
86
+ // The first start with no record yet takes the projects already in the owner's list as
87
+ // approved, as they stand, once. A project added later gets no such pass: adding one makes
88
+ // the record first, from the list as it was (registerProject). The record is written whole
89
+ // beside its place and linked in, so of two first starts one record wins and both read it.
90
+ // That pass is given once: a mark beside the record says it was used. A record that goes
91
+ // missing after that is made again empty, so every file is asked about and the loss
92
+ // approves nothing.
93
+ function loadStore() {
94
+ const saved = readStore(); if (saved) return saved;
95
+ const store = { version: 1, projects: {} }, file = storeFile(), mark = path.join(path.dirname(file), 'project-trust.once'); noSymlinks(mark);
96
+ if (!fs.existsSync(mark)) for (const root of listedProjects()) for (const name of TRUST_FILES) {
97
+ let now = null; try { now = current(root, name); } catch { /* A file that cannot be read is asked about later. */ }
98
+ if (now && !now.large) answer(store, root, name, now, true);
99
+ }
100
+ const part = `${file}.${crypto.randomUUID()}.tmp`; mkdir(path.dirname(file));
101
+ fs.writeFileSync(part, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
102
+ try { fs.linkSync(part, file); } catch (error) { if (error.code !== 'EEXIST') throw error; } finally { fs.unlinkSync(part); }
103
+ try { fs.writeFileSync(mark, '', { mode: 0o600, flag: 'wx' }); } catch (error) { if (error.code !== 'EEXIST') throw error; }
104
+ return readStore();
105
+ }
106
+ export function ensureTrustStore() { loadStore(); }
107
+
108
+ // What is in force for one file. Its text as it stands, when the owner approved exactly that
109
+ // content. Otherwise the text he approved last: a file that changed, that he turned down,
110
+ // that grew too large or that is gone changes nothing until he approves the change. null
111
+ // when he never approved any text, or approved going without the file.
112
+ function inForce(store, root, name) {
113
+ const now = current(root, name), row = entryOf(store, keyOf(root), name);
114
+ if (now && !now.large && row.approved.includes(now.hash)) return { text: now.text, asItStands: true };
115
+ if (!now && wentWithout(row)) return null;
116
+ return row.text === null ? null : { text: row.text, asItStands: false };
117
+ }
118
+ // Every one of the project's files there is something to say about, with what the owner said
119
+ // about it as it stands: approved, declined, new (never asked about this file) or changed
120
+ // (asked before, about other content). A file that is gone is listed while a text he
121
+ // approved is in force in its place (gone). large: over the limit, never used. before and
122
+ // at: the text in force in its place and when he approved it; null when nothing is.
123
+ export function trustRows(root) {
124
+ const store = loadStore(), key = keyOf(root);
125
+ return TRUST_FILES.flatMap(name => {
126
+ let now; try { now = current(root, name); } catch { return []; }
127
+ const row = entryOf(store, key, name);
128
+ if (!now) { if (row.text === null || wentWithout(row)) return []; now = { gone: true, hash: GONE, text: null }; }
129
+ const state = !now.large && !now.gone && row.approved.includes(now.hash) ? 'approved' : row.declined.includes(now.hash) ? 'declined' : row.approved.length || row.declined.length ? 'changed' : 'new';
130
+ const kept = state !== 'approved' && row.text !== null;
131
+ return [{ name, state, hash: now.hash, text: now.text, before: kept ? row.text : null, at: kept ? row.at : null, ...(now.large ? { large: true, size: now.size } : {}), ...(now.gone ? { gone: true } : {}) }];
132
+ });
133
+ }
134
+ // The files waiting for an answer, and all the files that are not in use as they stand.
135
+ export const pendingTrust = root => trustRows(root).filter(row => row.state === 'new' || row.state === 'changed');
136
+ export const unusedTrust = root => trustRows(root).filter(row => row.state !== 'approved');
137
+ const known = name => { if (!TRUST_FILES.includes(name)) refuse('That is not a project file ELU Code reads.'); };
138
+ // The text in force for this file, else null: the file's own when the owner approved exactly
139
+ // that content on this machine, otherwise the text he approved last. For the file as it
140
+ // stands, the text returned is the text that was hashed, so what is used is what was approved.
141
+ export function trustedText(root, name) { known(name); return inForce(loadStore(), root, name)?.text ?? null; }
142
+ // Whether the file as it stands is the owner's approved text: only then is it his to remove.
143
+ export function approvedAsItStands(root, name) { known(name); return inForce(loadStore(), root, name)?.asItStands === true; }
144
+ // Records the owner's answer for the content he was shown (row, from trustRows), whatever
145
+ // the file holds by now: a file changed since then is asked about again. Only text that can
146
+ // be reviewed is ever approved, whoever asks.
147
+ export function answerTrust(root, row, use) {
148
+ if (!TRUST_FILES.includes(row?.name) || !/^[a-f0-9]{64}$/.test(row.hash ?? '')) refuse('That is not a project file ELU Code reads.');
149
+ if (use === true && row.hash !== GONE && (typeof row.text !== 'string' || Buffer.byteLength(row.text) > TEXT_LIMIT)) refuse('That file is too large to use. Keep it under 100 KB.');
150
+ const store = structuredClone(loadStore()); answer(store, root, row.name, row, use === true);
151
+ atomic(storeFile(), store);
152
+ }
153
+ // What ELU Code itself writes on the owner's action is approved by that save. Call it
154
+ // before the write, so no session starts in between and finds the new file unapproved.
155
+ export function approveSaved(root, name, text) { answerTrust(root, { name, hash: hash(text), text }, true); }
156
+ // The owner cleared the text, or took the file out, through ELU Code: he goes without it and
157
+ // nothing stays in force.
158
+ export function approveRemoved(root, name) { answerTrust(root, { name, hash: GONE }, true); }
159
+
160
+ const day = at => Number.isFinite(Date.parse(at ?? '')) ? new Date(at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : null;
161
+ // " on Nov 10, 2026", or nothing when the record holds no date.
162
+ export const approvedOn = at => day(at) ? ` on ${day(at)}` : '';
163
+ const WHY = { gone: ['is gone', 'are gone'], large: ['is now too large to use', 'are now too large to use'], changed: ['has changed since', 'have changed since'] };
164
+ // What a session is told when some of these files are not in use as they stand: which it
165
+ // ran without, and which it ran on as approved earlier. key changes with their content.
166
+ export function unapprovedNote(root) {
167
+ const rows = unusedTrust(root); if (!rows.length) return null;
168
+ const list = files => { const names = files.map(row => `.elu-code/${row.name}`); return names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names.at(-1)}` : names[0]; };
169
+ const without = rows.filter(row => row.before === null), many = without.length > 1, said = [], kept = new Map();
170
+ if (without.length) said.push(`Running without this project's ${list(without)}: ${without.every(row => row.large) ? `${many ? 'they are' : 'it is'} too large to use` : `you have not approved ${many ? 'them' : 'it'} on this machine`}.`);
171
+ // Files approved on the same day and set aside for the same reason share a sentence.
172
+ for (const row of rows.filter(row => row.before !== null)) { const key = `${approvedOn(row.at)}\0${row.gone ? 'gone' : row.large ? 'large' : 'changed'}`; kept.set(key, [...(kept.get(key) ?? []), row]); }
173
+ for (const [key, files] of kept) { const [on, why] = key.split('\0'); said.push(`Running on the ${list(files)} you approved${on}: the file${files.length > 1 ? 's' : ''} ${WHY[why][files.length > 1 ? 1 : 0]}.`); }
174
+ return { key: hash(JSON.stringify(rows.map(row => [row.name, row.hash]))), text: `${said.join(' ')} To review ${rows.length > 1 ? 'them' : 'it'}, open Projects and choose this project.` };
175
+ }
176
+ export const NOT_APPROVED = "This project's settings (.elu-code/config.yaml) are not approved on this machine. Open ELU Code in this folder, answer its question about them, then run this again.";
177
+
178
+ // What the owner is shown before he answers. Nothing in the file is hidden: a character that
179
+ // would not show, or would move the text around, is written out as its code.
180
+ const HIDDEN = /[\p{Cc}\p{Cf}\p{Co}\p{Cn}\p{Zl}\p{Zp}\u115f\u1160\u2800\u3164\uffa0]/gu;
181
+ const shown = line => line.replace(/\t/g, ' ').replace(HIDDEN, ch => `<U+${ch.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')}>`);
182
+ const linesOf = text => { const lines = text.replace(/\r\n/g, '\n').split('\n'); if (lines.at(-1) === '') lines.pop(); return lines; };
183
+ // A long run of empty lines is named, so padding cannot push text out of sight.
184
+ function wholeText(text) {
185
+ const out = []; let blank = 0;
186
+ const flush = () => { if (blank > 2) out.push(`(${blank} empty lines)`); else for (let i = 0; i < blank; i++) out.push(''); blank = 0; };
187
+ for (const line of linesOf(text).map(shown)) { if (line.trim()) { flush(); out.push(line); } else blank += 1; }
188
+ flush();
189
+ return out.length ? out : ['(the file is empty)'];
190
+ }
191
+ // The lines that differ between two texts: "- " was removed, "+ " is new, "…" stands for
192
+ // lines that stayed. null when the texts are too long to compare.
193
+ function changedLines(before, after) {
194
+ const a = linesOf(before), b = linesOf(after), m = a.length, n = b.length;
195
+ if (m * n > 1_000_000) return null;
196
+ const table = Array.from({ length: m + 1 }, () => new Uint32Array(n + 1));
197
+ for (let i = m - 1; i >= 0; i--) for (let j = n - 1; j >= 0; j--) table[i][j] = a[i] === b[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
198
+ const out = []; let i = 0, j = 0, kept = false;
199
+ while (i < m || j < n) {
200
+ if (i < m && j < n && a[i] === b[j]) { i += 1; j += 1; kept = out.length > 0; continue; }
201
+ if (kept) { out.push('…'); kept = false; }
202
+ if (i < m && (j === n || table[i + 1][j] >= table[i][j + 1])) out.push(`- ${shown(a[i++])}`); else out.push(`+ ${shown(b[j++])}`);
203
+ }
204
+ // A long run of empty lines that came or went is named, like in a whole text.
205
+ const brief = [], empty = line => /^[+-]\s*$/.test(line);
206
+ for (let at = 0; at < out.length;) {
207
+ let end = at; while (end < out.length && empty(out[end]) && out[end][0] === out[at][0]) end += 1;
208
+ if (end - at > 2) { brief.push(`${out[at][0]} (${end - at} empty lines)`); at = end; } else brief.push(out[at++]);
209
+ }
210
+ return brief;
211
+ }
212
+ const KIND = { 'config.yaml': 'settings', 'instructions.md': 'instructions', 'context.md': 'notes' };
213
+ const USE = { 'config.yaml': 'They set the model, the commands the model may run, and the checks ELU Code runs.', 'instructions.md': 'The model follows them as yours.', 'context.md': 'The model reads them.' };
214
+ // row: one of trustRows. The first line says what the file is and why he is asked. While a
215
+ // text he approved is in force in the file's place, it says so: the change waits for him.
216
+ export function reviewLines(row) {
217
+ const kind = KIND[row.name], use = USE[row.name], kept = typeof row.before === 'string', on = approvedOn(row.at);
218
+ const version = `The version you approved${on} stays in use until you choose otherwise.`;
219
+ if (row.large) return [`This project's ${kind} file is too large to review: ${Math.ceil(row.size / 1024)} KB, and the limit is 100 KB. It is not used.${kept ? ` ${version}` : ''}`];
220
+ if (row.gone) return [`This project's ${kind} file is gone. ${version} It reads:`, '', ...wholeText(row.before)];
221
+ if (!kept) {
222
+ if (row.state === 'declined') return [`You chose not to use this project's ${kind}. ${use} The file reads:`, '', ...wholeText(row.text)];
223
+ if (row.state !== 'changed') return [`This project came with ${kind}. ${use} They are used only if you say so.`, '', ...wholeText(row.text)];
224
+ return [`This project's ${kind} changed. ${use} The file now reads:`, '', ...wholeText(row.text)];
225
+ }
226
+ const lines = changedLines(row.before, row.text);
227
+ const first = row.state === 'declined' ? `You kept the ${kind} you approved${on}. The file in the folder is different.` : `This project's ${kind} changed since you approved them${on}. The approved version stays in use until you choose otherwise.`;
228
+ if (lines?.length) return [`${first} Lines with - were removed and lines with + are new.`, '', ...lines];
229
+ return [`${first} The file now reads:`, '', ...wholeText(row.text)];
230
+ }