atris 3.82.26 → 3.83.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 (39) hide show
  1. package/AGENTS.md +10 -12
  2. package/README.md +77 -0
  3. package/bin/atris.js +95 -44
  4. package/commands/activate.js +39 -33
  5. package/commands/agent-home.js +403 -0
  6. package/commands/agent-signin.js +214 -0
  7. package/commands/agent-spawn.js +1 -0
  8. package/commands/autoland.js +173 -71
  9. package/commands/brain.js +211 -410
  10. package/commands/doctor.js +38 -26
  11. package/commands/init.js +4 -4
  12. package/commands/join.js +159 -0
  13. package/commands/memory.js +2 -2
  14. package/commands/morning.js +132 -0
  15. package/commands/night.js +178 -0
  16. package/commands/recap.js +4 -4
  17. package/commands/task.js +31 -2
  18. package/lib/agent-home.js +289 -0
  19. package/lib/agent-page.js +1257 -0
  20. package/lib/cleanup-summary.js +4 -2
  21. package/lib/first-minute.js +41 -20
  22. package/lib/gm-phone-page.js +8 -2
  23. package/lib/known-commands.js +1 -1
  24. package/lib/member-export.js +31 -0
  25. package/lib/memory-folder.js +122 -24
  26. package/lib/memory-live.js +2 -2
  27. package/lib/night-learned.js +171 -0
  28. package/lib/night-shift.js +123 -0
  29. package/lib/task-core.js +2 -1
  30. package/lib/task-db.js +32 -0
  31. package/package.json +1 -1
  32. package/templates/members/designer-1/MEMBER.md +34 -0
  33. package/templates/members/designer-1/MISSION.md +11 -0
  34. package/templates/members/designer-1/SOUL.md +11 -0
  35. package/templates/members/designer-1/context/why.md +5 -0
  36. package/templates/members/designer-1/goals.json +5 -0
  37. package/templates/members/designer-1/goals.md +3 -0
  38. package/templates/members/designer-1/skills/designer-1/SKILL.md +179 -0
  39. package/templates/members/designer-1/skills/designer-1/parts.md +55 -0
@@ -76,9 +76,11 @@ function parkedBranchCount(root) {
76
76
 
77
77
  function shorten(text, max) {
78
78
  if (text.length <= max) return text;
79
- const cut = text.slice(0, max - 1);
79
+ const cut = text.slice(0, Math.max(0, max - 3));
80
80
  const stop = Math.max(cut.lastIndexOf('; '), cut.lastIndexOf(', '));
81
- return `${(stop > max * 0.5 ? cut.slice(0, stop) : cut).replace(/[\s,;.]+$/, '')}...`;
81
+ const wordEnd = /\s/.test(text[cut.length] || '') ? cut.length : cut.lastIndexOf(' ');
82
+ const end = stop > max * 0.5 ? stop : Math.max(0, wordEnd);
83
+ return `${cut.slice(0, end).replace(/[\s,;.]+$/, '')}...`;
82
84
  }
83
85
 
84
86
  // The newest cleanup line from today's journal (yesterday's if today has
@@ -133,11 +133,31 @@ function hasLiveWork({
133
133
  return Boolean(task || backlogCount > 0 || wipCount > 0 || inboxCount > 0);
134
134
  }
135
135
 
136
- function softTitle(title, maxWords = 5) {
137
- const words = String(title || '').replace(/\s+/g, ' ').trim().split(' ').filter(Boolean);
138
- if (!words.length) return '';
139
- const text = words.slice(0, maxWords).join(' ').replace(/[.,;:!?]+$/g, '');
140
- return `"${text.toLowerCase()}"`;
136
+ // The first task every new folder gets is written for agents ("Generate
137
+ // MAP.md, scan codebase, ..."). A person sees what it means instead.
138
+ function plainTitle(title) {
139
+ const text = String(title || '');
140
+ return /^\s*generate map\.md\b/i.test(text) ? 'map this folder so agents can find things' : text;
141
+ }
142
+
143
+ function softTitle(title) {
144
+ const text = plainTitle(title).replace(/\s+/g, ' ').trim();
145
+ if (!text) return '';
146
+ let shortened = text;
147
+ if (text.length > 60) {
148
+ const part = text.slice(0, 57);
149
+ const boundary = part.lastIndexOf(' ');
150
+ shortened = `${(boundary > 0 ? part.slice(0, boundary) : part).replace(/[.,;:!?]+$/g, '')}...`;
151
+ } else {
152
+ shortened = text.replace(/[.,;:!?]+$/g, '');
153
+ }
154
+ return `"${shortened.toLowerCase()}"`;
155
+ }
156
+
157
+ // The greeting is two lines: what is already here, then one command. The
158
+ // command sits alone on its line so it can be copied and pasted as is.
159
+ function nextLine(command) {
160
+ return `next: ${command}`;
141
161
  }
142
162
 
143
163
  function taskRef(task) {
@@ -723,7 +743,7 @@ function renderLaterRemember({ person = '', sentence = '', files, root, later }
723
743
  return [
724
744
  `${greet(person)}I'll remember ${remembered}.`,
725
745
  '',
726
- `next: ${freshNextCommand(files, 'this folder', { root, later: remembered })}`,
746
+ nextLine(freshNextCommand(files, 'this folder', { root, later: remembered })),
727
747
  ].join('\n');
728
748
  }
729
749
 
@@ -733,7 +753,7 @@ function renderFresh({ person = '', folder = 'this folder', files, root, commit,
733
753
  return [
734
754
  `${greet(person)}${freshWinLine({ folder: room, files, root, commit, dirty, branch, later })}`,
735
755
  '',
736
- `next: ${freshNextCommand(files, room, extra)}`,
756
+ nextLine(freshNextCommand(files, room, extra)),
737
757
  ].join('\n');
738
758
  }
739
759
 
@@ -803,7 +823,7 @@ function renderFirstTalk({ person = '', folder = 'this folder', starter = null,
803
823
  return [
804
824
  `${greet(person)}${title} is started. nothing is built yet.`,
805
825
  '',
806
- `next: ${build || firstTalkNext({ starter, person, folder: room })}`,
826
+ nextLine(build || firstTalkNext({ starter, person, folder: room })),
807
827
  ].join('\n');
808
828
  }
809
829
 
@@ -829,7 +849,7 @@ function renderStarterNewWork({ person = '', task = null, files = [], folder = '
829
849
  return [
830
850
  `${greet(person)}${starterTitle(task, folder)} has new work: ${spokenBuiltList(files)}.`,
831
851
  '',
832
- 'next: atris done',
852
+ nextLine('atris done'),
833
853
  ].join('\n');
834
854
  }
835
855
 
@@ -842,9 +862,9 @@ function shippedHumanStarter(tasks) {
842
862
 
843
863
  function renderStarterShipped({ person = '', task = null, folder = 'this folder' } = {}) {
844
864
  return [
845
- `${greet(person)}you already shipped ${starterTitle(task, folder)}.`,
865
+ `${greet(person)}you already finished ${starterTitle(task, folder)}.`,
846
866
  '',
847
- `next: ${WHATS_NEXT_COMMAND}`,
867
+ nextLine(WHATS_NEXT_COMMAND),
848
868
  ].join('\n');
849
869
  }
850
870
 
@@ -852,7 +872,7 @@ function renderWhatsNextAsk({ person = '', task = null, folder = 'this folder' }
852
872
  return [
853
873
  `${greet(person)}${starterTitle(task, folder)} is shipped. say the next thing you want here, in your own words.`,
854
874
  '',
855
- 'next: atris "<the next thing you want>"',
875
+ nextLine('atris "<the next thing you want>"'),
856
876
  ].join('\n');
857
877
  }
858
878
 
@@ -886,26 +906,26 @@ function renderWorkspace({
886
906
  const title = task && softTitle(task.title);
887
907
  let win = `${who}${folder || 'this folder'} is set up.`;
888
908
  if (task && task.status === 'done' && title) {
889
- win = `${who}you already shipped ${title}.`;
909
+ win = `${who}you already finished ${title}.`;
890
910
  } else if (task && task.status === 'review') {
891
- win = `${who}something finished. waiting on you.`;
911
+ win = `${who}${title || 'this work'} is waiting for your ok.`;
892
912
  } else if (task && task.status === 'claimed' && title) {
893
- win = `${who}${title} is already yours.`;
913
+ win = `${who}you are working on ${title}.`;
894
914
  } else if (task && task.status === 'open' && title) {
895
- win = `${who}${title} is ready to claim.`;
915
+ win = `${who}${title} is ready to start.`;
896
916
  } else if (movingTitle && liveWork) {
897
917
  win = `${who}${softTitle(movingTitle)} is waiting.`;
898
918
  } else if (completedTitle && !liveWork) {
899
- win = `${who}you already shipped ${softTitle(completedTitle)}.`;
919
+ win = `${who}you already finished ${softTitle(completedTitle)}.`;
900
920
  } else if (recap && recap.title && !liveWork) {
901
921
  win = `${who}you already have a recap in ${folder || 'this folder'}: ${recap.title}.`;
902
922
  } else if (liveWork) {
903
- win = `${who}${folder || 'this folder'} has work in motion.`;
923
+ win = `${who}${folder || 'this folder'} has work in progress.`;
904
924
  }
905
925
  const lines = [win];
906
926
  if (recap && recap.title && task) lines.push(`last recap: ${recap.title}.`);
907
927
  lines.push('');
908
- lines.push(`next: ${nextCommand}`);
928
+ lines.push(nextLine(nextCommand));
909
929
  return lines.join('\n');
910
930
  }
911
931
 
@@ -1138,7 +1158,7 @@ function speakNothingRunning({
1138
1158
  return 2;
1139
1159
  }
1140
1160
  log('');
1141
- log(`${greet(personName())}nothing is running.\n\nnext: ${next}`);
1161
+ log(`${greet(personName())}nothing is running.\n\n${nextLine(next)}`);
1142
1162
  return 0;
1143
1163
  }
1144
1164
 
@@ -1186,6 +1206,7 @@ function speakKeepWorkingMinute({
1186
1206
  }
1187
1207
 
1188
1208
  module.exports = {
1209
+ plainTitle,
1189
1210
  buildFirstMinute,
1190
1211
  deskNextCommand,
1191
1212
  firstTalkCommand,
@@ -448,15 +448,21 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
448
448
  if (w.text === node.textContent && w.bad === node.classList.contains('bad')) return;
449
449
  busy = true;
450
450
  fading = true;
451
+ var ns = getComputedStyle(node), seen = Boolean(node.textContent) && ns.display !== 'none' && Number(ns.opacity) > 0;
451
452
  node.classList.add('out');
452
- setTimeout(function () {
453
+ var swap = function () {
453
454
  node.textContent = w.text;
454
455
  node.classList.toggle('bad', w.bad);
455
456
  node.classList.remove('out');
456
457
  fading = false;
457
458
  // each line stays long enough to read before the next one
458
459
  setTimeout(function () { busy = false; next(); }, 650);
459
- }, reduced ? 200 : 280);
460
+ };
461
+ // the words change once their fade has run all the way. A timer alone
462
+ // emptied them a frame or two early, as the fade starts a frame late, and
463
+ // on a machine drawing without a gpu they vanished at 0.17. With no
464
+ // words in sight there is no fade to wait out, so the timer stays.
465
+ if (seen) afterFade(node, reduced ? 200 : 280, swap); else setTimeout(swap, reduced ? 200 : 280);
460
466
  }
461
467
  return {
462
468
  set: function (text, bad) { want = { text: text, bad: Boolean(bad) }; next(); },
@@ -5,7 +5,7 @@ const knownCommands = ['init', 'guide', 'doctor', 'doc-health', 'install', 'work
5
5
  'clean', 'close', 'harvest', 'verify', 'recover', 'search', 'scout', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'taste', 'teach', 'plugin', 'experiments', 'bench', 'router', 'tree', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'watch', 'cloud', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube', 'x-search',
6
6
  'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'decide', 'campaign', 'agents', 'probe', 'worktree', 'land', 'caretaker', 'autoland', 'drive', 'aeo', 'slop', 'voice', 'strings', 'write', 'security-review', 'secure', 'sentinel', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'scoreboard', 'growth', 'scorecard', 'rating', 'sync-checkout', 'zen',
7
7
  'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'memory', 'balance', 'film', 'usage', 'spend', 'api-key', 'agent-client', 'topup', 'buy', 'transactions', 'hire', 'hires', 'mail', 'channel', 'listen', 'design', 'mcp', 'gmail', 'google', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
8
- 'fork', 'browse', 'publish', 'pack', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'fleet-report', 'loops', 'self-improve', 'compile', 'spaceship', 'truth', 'sign', 'engine', 'engines', 'link', 'playbook', 'feed', 'brief', 'social', 'host', 'people', 'follow', 'unfollow', 'friends', 'msg', 'inbox', 'invite', 'join', 'rsi', 'pay', 'trust'];
8
+ 'fork', 'browse', 'publish', 'pack', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'fleet-report', 'loops', 'self-improve', 'compile', 'spaceship', 'truth', 'sign', 'engine', 'engines', 'link', 'playbook', 'feed', 'brief', 'social', 'host', 'people', 'follow', 'unfollow', 'friends', 'msg', 'inbox', 'invite', 'join', 'night', 'morning', 'rsi', 'pay', 'trust'];
9
9
 
10
10
  // Damerau-Levenshtein edit distance between two short strings. Counts an
11
11
  // adjacent transposition (e.g. "taks" -> "task") as a single edit, since
@@ -8,6 +8,10 @@
8
8
  // codex .codex/agents/<name>.toml agent role file (name, description, developer_instructions);
9
9
  // codex 0.162 discovers roles in .codex/agents
10
10
  //
11
+ // Each file carries the whole identity: MEMBER.md's body, then SOUL.md,
12
+ // MISSION.md and goals.md when the member has them, so an outside engine
13
+ // loads who the member is and what it is for, not only its rules.
14
+ //
11
15
  // Every file carries GENERATED_MARK. Export only overwrites or removes files
12
16
  // that carry it, so a hand-written agent with the same name is left alone.
13
17
  // Parked members are skipped, and their old generated files are removed.
@@ -68,6 +72,28 @@ function memberSkills(dir, fields) {
68
72
  return [...names].sort();
69
73
  }
70
74
 
75
+ // The body of an identity file next to MEMBER.md, frontmatter and HTML
76
+ // comments removed, or '' when the member has none.
77
+ function identityText(dir, file, text) {
78
+ if (text === undefined) {
79
+ try {
80
+ text = fs.readFileSync(path.join(dir, file), 'utf8');
81
+ } catch {
82
+ return '';
83
+ }
84
+ }
85
+ if (text === null) return '';
86
+ return frontmatterOf(text).body.replace(/<!--[\s\S]*?-->/g, '').replace(/\n{3,}/g, '\n\n').trim();
87
+ }
88
+
89
+ // The files that say who a member is beyond MEMBER.md, in the order an
90
+ // engine should read them.
91
+ const IDENTITY_FILES = [
92
+ ['soul', 'SOUL.md', 'Soul'],
93
+ ['mission', 'MISSION.md', 'Mission'],
94
+ ['goals', 'goals.md', 'Goals'],
95
+ ];
96
+
71
97
  function readMembers(root) {
72
98
  const teamDir = path.join(root, 'atris', 'team');
73
99
  let entries = [];
@@ -100,6 +126,7 @@ function readMembers(root) {
100
126
  model: scalar(fields.model),
101
127
  skills: memberSkills(dir, fields),
102
128
  body: body.trim(),
129
+ ...Object.fromEntries(IDENTITY_FILES.map(([key, file]) => [key, identityText(dir, file)])),
103
130
  });
104
131
  }
105
132
  return { members, parked };
@@ -126,6 +153,9 @@ function instructions(member) {
126
153
  '',
127
154
  ];
128
155
  if (member.body) lines.push(member.body, '');
156
+ for (const [key, file, title] of IDENTITY_FILES) {
157
+ if (member[key]) lines.push(`## ${title} (atris/team/${member.slug}/${file})`, '', member[key], '');
158
+ }
129
159
  if (member.skills.length) {
130
160
  lines.push('## Skills', '');
131
161
  for (const skill of member.skills) {
@@ -233,6 +263,7 @@ function summaryLine(result, { dryRun = false } = {}) {
233
263
 
234
264
  module.exports = {
235
265
  GENERATED_MARK,
266
+ identityText,
236
267
  agentName,
237
268
  exportMembers,
238
269
  frontmatterOf,
@@ -11,13 +11,16 @@ const crypto = require('crypto');
11
11
  const transfer = require('./memory-files');
12
12
 
13
13
  const STATE_FILE = '.atris-memory.json';
14
- const WRITABLE_PREFIXES = ['wiki/', 'files/'];
14
+ const WRITABLE_PREFIXES = ['wiki/', 'files/', 'features/'];
15
15
  const MAX_WRITE_BYTES = 1000000;
16
16
  const READ_PAGE = 400;
17
17
  const FETCH_PARALLEL = 6;
18
18
  const MAX_FOLDERS = 2000;
19
19
  const MAX_LOCAL_FILES = 20000;
20
20
  const MAX_DEPTH = 24;
21
+ const MAX_BUNDLE_HAVE = 5000;
22
+ // Twice the maximum folder size, even if every answer advances by just one file.
23
+ const MAX_BUNDLE_ANSWERS = 10000;
21
24
 
22
25
  const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
23
26
 
@@ -189,6 +192,7 @@ function memoryClient(request, token) {
189
192
  rawFileMissing: false,
190
193
  filesSupported: null, // false once the server shows it has no file links yet
191
194
  sizes: new Map(), // bytes of each file, from the listing
195
+ etags: new Map(),
192
196
 
193
197
  async list() {
194
198
  const found = [];
@@ -207,7 +211,7 @@ function memoryClient(request, token) {
207
211
  for (const item of items) {
208
212
  const name = String(item.name || '');
209
213
  const full = folder ? `${folder}/${name}` : name;
210
- if (!name.endsWith('/')) { found.push(full); client.sizes.set(full, Number(item.size)); }
214
+ if (!name.endsWith('/')) { found.push(full); client.sizes.set(full, Number(item.size)); if (item.etag) client.etags.set(full, item.etag); }
211
215
  else if (cleanRel(full.slice(0, -1))) level.push(full.slice(0, -1));
212
216
  else found.push(full); // never descended; pull counts it as an unsafe name
213
217
  }
@@ -216,12 +220,24 @@ function memoryClient(request, token) {
216
220
  return found.sort();
217
221
  },
218
222
 
223
+ async bundle(have, after) {
224
+ const result = await call('/memory/bundle', { method: 'POST', body: { have, after } });
225
+ if (result.status === 404 || result.status === 405) return null;
226
+ if (!result.ok) throw apiError(result, 'could not download the folder');
227
+ const data = result.data;
228
+ if (!data || !Array.isArray(data.pages) || !Array.isArray(data.gone) || !Array.isArray(data.skipped)
229
+ || (data.next != null && (typeof data.next !== 'string' || !data.next))) {
230
+ throw new Error('could not download the folder: invalid bundle response');
231
+ }
232
+ return data;
233
+ },
234
+
219
235
  // { exists: false } when the server has no such file; throws on any other trouble.
220
236
  async read(rel) {
221
237
  if (!client.rawFileMissing) {
222
238
  const result = await call(`/memory/file?path=${query(rel)}`);
223
239
  if (result.ok && result.data && typeof result.data.text === 'string') {
224
- return { exists: true, text: result.data.text };
240
+ return { exists: true, text: result.data.text, etag: result.data.etag || client.etags.get(rel) };
225
241
  }
226
242
  if (result.status === 404) client.rawFileMissing = true;
227
243
  else if (/^No file at/.test(String(result.error || ''))) return { exists: false };
@@ -302,7 +318,7 @@ function noFiles(client) {
302
318
  // "changed": it changed since the last pull, so it is never overwritten.
303
319
  // Files fetched as files are compared by S3 fingerprint, pages by their text.
304
320
  async function serverCheck(client, rel, base, local, { asFile = false } = {}) {
305
- if ((asFile || (base && base.etag)) && client.filesSupported !== false) {
321
+ if ((asFile || (base && base.etag && !base.page)) && client.filesSupported !== false) {
306
322
  const info = await client.link(rel);
307
323
  if (!info.exists) return { verdict: 'clear', exists: false };
308
324
  if (local && info.md5 && info.md5 === local.md5 && info.bytes === local.bytes) {
@@ -430,17 +446,15 @@ async function pullFolder({ dir, client, account = null, now = () => new Date(),
430
446
  const rootReal = fs.realpathSync(dir);
431
447
  const state = loadState(rootReal) || { version: 1, files: {} };
432
448
  checkAccount(state, account);
449
+ const originalState = JSON.stringify(state);
433
450
  if (account) state.account = account;
451
+ const out = { total: 0, added: [], updated: [], same: 0, keptLocal: [], conflicts: [], failed: [], unsafe: [], gone: 0 };
452
+ const movedAsFile = (rel) => Boolean(state.files[rel] && state.files[rel].etag && !state.files[rel].page);
434
453
 
435
- const remote = await client.list();
436
- const out = { total: remote.length, added: [], updated: [], same: 0, keptLocal: [], conflicts: [], failed: [], unsafe: [], gone: 0 };
437
- const movedAsFile = (rel) => Boolean(state.files[rel] && state.files[rel].etag);
438
- const asFiles = remote.filter((rel) => cleanRel(rel) && (transfer.goesAsFile(rel, client.sizes.get(rel)) || movedAsFile(rel)));
439
-
440
- const pullText = async (rel) => {
454
+ const pullText = async (rel, bundled = null) => {
441
455
  if (!cleanRel(rel)) { out.unsafe.push(rel); return; }
442
456
  let file;
443
- try { file = await client.read(rel); } catch (error) {
457
+ try { file = bundled || await client.read(rel); } catch (error) {
444
458
  // A .csv or .txt that is not UTF-8, sent from another laptop as a file: fetch it as one.
445
459
  if (rel.startsWith('files/') && client.filesSupported !== false
446
460
  && await pullFile({ rootReal, rel, client, state, out, onProgress })) return;
@@ -457,6 +471,7 @@ async function pullFolder({ dir, client, account = null, now = () => new Date(),
457
471
  try { localSha = transfer.hashFileSync(abs).sha256; } catch { /* no local copy */ }
458
472
  const { md5 } = transfer.printOf(Buffer.from(file.text, 'utf8'));
459
473
  const record = { sha256: remoteSha, bytes: Buffer.byteLength(file.text, 'utf8'), md5 };
474
+ if (file.etag) { record.etag = file.etag; record.page = true; }
460
475
  if (localSha === remoteSha) {
461
476
  out.same += 1;
462
477
  state.files[rel] = record;
@@ -469,6 +484,7 @@ async function pullFolder({ dir, client, account = null, now = () => new Date(),
469
484
  state.files[rel] = record;
470
485
  out.updated.push(rel);
471
486
  } else if (base && remoteSha === base) {
487
+ state.files[rel] = record;
472
488
  out.keptLocal.push(rel);
473
489
  } else {
474
490
  out.conflicts.push(rel);
@@ -477,26 +493,108 @@ async function pullFolder({ dir, client, account = null, now = () => new Date(),
477
493
  out.failed.push({ path: rel, error: error.message });
478
494
  }
479
495
  };
480
- const fileSet = new Set(asFiles);
481
- await mapLimit(remote.filter((rel) => !fileSet.has(rel)), FETCH_PARALLEL, pullText);
482
- await moveFiles(asFiles, (rel) => client.sizes.get(rel) || 0, async (rel) => {
483
- const done = client.filesSupported !== false && await pullFile({ rootReal, rel, client, state, out, onProgress });
484
- if (!done) await pullText(rel); // an older server: copy it as text, as before
485
- });
486
-
487
- const onServer = new Set(remote);
488
- for (const rel of Object.keys(state.files)) {
489
- if (!onServer.has(rel)) { delete state.files[rel]; out.gone += 1; }
496
+ // Only advertise copies that still exist locally, so a deleted local file is restored.
497
+ const have = {};
498
+ let haveCount = 0;
499
+ for (const [rel, base] of Object.entries(state.files)) {
500
+ if (haveCount >= MAX_BUNDLE_HAVE) break;
501
+ if (!cleanRel(rel) || !base.etag) continue;
502
+ try {
503
+ const abs = localTarget(rootReal, rel);
504
+ if (fs.existsSync(abs)) { have[rel] = base.etag; haveCount += 1; }
505
+ } catch { /* a path that cannot be copied must not be advertised */ }
506
+ }
507
+ let bundle = await client.bundle(have, '');
508
+ if (bundle) {
509
+ const changed = new Set();
510
+ const gone = new Set();
511
+ const skipped = new Map();
512
+ let after = '';
513
+ let answers = 0;
514
+ try {
515
+ do {
516
+ answers += 1;
517
+ for (const page of bundle.pages) {
518
+ if (!page || typeof page.path !== 'string' || typeof page.text !== 'string' || typeof page.etag !== 'string') {
519
+ throw new Error('could not download the folder: invalid bundle page');
520
+ }
521
+ changed.add(page.path);
522
+ await pullText(page.path, { exists: true, text: page.text, etag: page.etag });
523
+ }
524
+ for (const rel of bundle.gone) gone.add(rel);
525
+ for (const item of bundle.skipped) {
526
+ if (!item || typeof item.path !== 'string') throw new Error('could not download the folder: invalid skipped file');
527
+ skipped.set(item.path, item);
528
+ }
529
+ if (!bundle.next) break;
530
+ // Compare Unicode code points, matching the server's path ordering.
531
+ const previous = Array.from(after, (char) => char.codePointAt(0));
532
+ const next = Array.from(bundle.next, (char) => char.codePointAt(0));
533
+ const firstDifference = next.findIndex((value, index) => index >= previous.length || value !== previous[index]);
534
+ if (firstDifference < 0 || (firstDifference < previous.length && next[firstDifference] < previous[firstDifference])) {
535
+ throw new Error('could not download the folder: bundle cursor did not move forward');
536
+ }
537
+ if (answers >= MAX_BUNDLE_ANSWERS) throw new Error('could not download the folder: too many bundle answers');
538
+ after = bundle.next;
539
+ bundle = await client.bundle(have, after);
540
+ if (!bundle) throw new Error('could not download the folder: bundle stopped during download');
541
+ } while (true);
542
+ } catch (error) {
543
+ // Save completed pages, but never apply removals from an unfinished pass.
544
+ if (JSON.stringify(state) !== originalState) saveState(rootReal, state);
545
+ throw error;
546
+ }
547
+ for (const [rel, item] of skipped) {
548
+ if (!cleanRel(rel)) { out.unsafe.push(rel); continue; }
549
+ const done = client.filesSupported !== false && await pullFile({ rootReal, rel, client, state, out, onProgress });
550
+ if (!done) await pullText(rel);
551
+ }
552
+ for (const rel of Object.keys(have)) {
553
+ if (changed.has(rel) || gone.has(rel) || skipped.has(rel)) continue;
554
+ try {
555
+ const local = transfer.hashFileSync(localTarget(rootReal, rel));
556
+ if (local.sha256 === state.files[rel].sha256) out.same += 1;
557
+ else out.keptLocal.push(rel);
558
+ } catch (error) { out.failed.push({ path: rel, error: error.message }); }
559
+ }
560
+ for (const rel of gone) {
561
+ if (Object.hasOwn(state.files, rel)) { delete state.files[rel]; out.gone += 1; }
562
+ }
563
+ out.total = changed.size + skipped.size + Object.keys(have).filter((rel) => !changed.has(rel) && !gone.has(rel) && !skipped.has(rel)).length;
564
+ } else {
565
+ const remote = await client.list();
566
+ out.total = remote.length;
567
+ const asFiles = remote.filter((rel) => cleanRel(rel) && (transfer.goesAsFile(rel, client.sizes.get(rel)) || movedAsFile(rel)));
568
+ const fileSet = new Set(asFiles);
569
+ await mapLimit(remote.filter((rel) => !fileSet.has(rel)), FETCH_PARALLEL, async (rel) => {
570
+ const base = state.files[rel];
571
+ if (base && base.etag && client.etags.get(rel) === base.etag && have[rel]) {
572
+ try {
573
+ const local = transfer.hashFileSync(localTarget(rootReal, rel));
574
+ if (local.sha256 === base.sha256) out.same += 1; else out.keptLocal.push(rel);
575
+ } catch (error) { out.failed.push({ path: rel, error: error.message }); }
576
+ } else await pullText(rel);
577
+ });
578
+ await moveFiles(asFiles, (rel) => client.sizes.get(rel) || 0, async (rel) => {
579
+ const done = client.filesSupported !== false && await pullFile({ rootReal, rel, client, state, out, onProgress });
580
+ if (!done) await pullText(rel);
581
+ });
582
+ const onServer = new Set(remote);
583
+ for (const rel of Object.keys(state.files)) {
584
+ if (!onServer.has(rel)) { delete state.files[rel]; out.gone += 1; }
585
+ }
586
+ }
587
+ if (JSON.stringify(state) !== originalState || !fs.existsSync(path.join(rootReal, STATE_FILE))) {
588
+ state.pulled_at = now().toISOString();
589
+ saveState(rootReal, state);
490
590
  }
491
- state.pulled_at = now().toISOString();
492
- saveState(rootReal, state);
493
591
  for (const key of ['added', 'updated', 'keptLocal', 'conflicts', 'unsafe']) out[key].sort();
494
592
  out.usedLineFallback = client.usedLineFallback;
495
593
  return out;
496
594
  }
497
595
 
498
596
  // Remove from the server the pages deleted here since the last pull. Only
499
- // wiki/ and files/, and only while the server copy is still the one pulled, so
597
+ // wiki/, files/ and features/, and only while the server copy is still the one pulled, so
500
598
  // a page someone changed elsewhere is never lost. onPrune hears the list first.
501
599
  async function pruneDeleted({ state, client, deleted, out, onPrune }) {
502
600
  const doomed = deleted.filter(isRemovable).sort();
@@ -28,8 +28,8 @@ const USAGE_LINES = {
28
28
  ls: row('ls [folder]', 'what is in a folder; MAP.md says what is where'),
29
29
  remember: row('remember "one short line"', 'a lasting fact or preference about the person, when they',
30
30
  'say remember, note, save or don\'t forget [--to now|learning]'),
31
- write: row('write <path> [--file f]', 'save a page under wiki/ or a file under files/, or pipe it in'),
32
- rm: row('rm <path>', 'delete a page under wiki/ or a file under files/'),
31
+ write: row('write <path> [--file f]', 'save a page under wiki/ or features/, or a file under files/, or pipe it in'),
32
+ rm: row('rm <path>', 'delete a page under wiki/ or features/, or a file under files/'),
33
33
  usage: row('usage', 'file space used and what the plan gives'),
34
34
  log: row('log "what I did" --as <agent>', 'one line in your notebook, team/<agent>/logs/<today>'),
35
35
  lesson: row('lesson "text" --before N --after N', 'kept in team/<agent>/lessons.md only when the score', 'went up (--as <agent>)'),