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.
- package/AGENTS.md +10 -12
- package/README.md +77 -0
- package/bin/atris.js +95 -44
- package/commands/activate.js +39 -33
- package/commands/agent-home.js +403 -0
- package/commands/agent-signin.js +214 -0
- package/commands/agent-spawn.js +1 -0
- package/commands/autoland.js +173 -71
- package/commands/brain.js +211 -410
- package/commands/doctor.js +38 -26
- package/commands/init.js +4 -4
- package/commands/join.js +159 -0
- package/commands/memory.js +2 -2
- package/commands/morning.js +132 -0
- package/commands/night.js +178 -0
- package/commands/recap.js +4 -4
- package/commands/task.js +31 -2
- package/lib/agent-home.js +289 -0
- package/lib/agent-page.js +1257 -0
- package/lib/cleanup-summary.js +4 -2
- package/lib/first-minute.js +41 -20
- package/lib/gm-phone-page.js +8 -2
- package/lib/known-commands.js +1 -1
- package/lib/member-export.js +31 -0
- package/lib/memory-folder.js +122 -24
- package/lib/memory-live.js +2 -2
- package/lib/night-learned.js +171 -0
- package/lib/night-shift.js +123 -0
- package/lib/task-core.js +2 -1
- package/lib/task-db.js +32 -0
- package/package.json +1 -1
- package/templates/members/designer-1/MEMBER.md +34 -0
- package/templates/members/designer-1/MISSION.md +11 -0
- package/templates/members/designer-1/SOUL.md +11 -0
- package/templates/members/designer-1/context/why.md +5 -0
- package/templates/members/designer-1/goals.json +5 -0
- package/templates/members/designer-1/goals.md +3 -0
- package/templates/members/designer-1/skills/designer-1/SKILL.md +179 -0
- package/templates/members/designer-1/skills/designer-1/parts.md +55 -0
package/lib/cleanup-summary.js
CHANGED
|
@@ -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 -
|
|
79
|
+
const cut = text.slice(0, Math.max(0, max - 3));
|
|
80
80
|
const stop = Math.max(cut.lastIndexOf('; '), cut.lastIndexOf(', '));
|
|
81
|
-
|
|
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
|
package/lib/first-minute.js
CHANGED
|
@@ -133,11 +133,31 @@ function hasLiveWork({
|
|
|
133
133
|
return Boolean(task || backlogCount > 0 || wipCount > 0 || inboxCount > 0);
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const text =
|
|
140
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
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
|
|
865
|
+
`${greet(person)}you already finished ${starterTitle(task, folder)}.`,
|
|
846
866
|
'',
|
|
847
|
-
|
|
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
|
-
'
|
|
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
|
|
909
|
+
win = `${who}you already finished ${title}.`;
|
|
890
910
|
} else if (task && task.status === 'review') {
|
|
891
|
-
win = `${who}
|
|
911
|
+
win = `${who}${title || 'this work'} is waiting for your ok.`;
|
|
892
912
|
} else if (task && task.status === 'claimed' && title) {
|
|
893
|
-
win = `${who}${title}
|
|
913
|
+
win = `${who}you are working on ${title}.`;
|
|
894
914
|
} else if (task && task.status === 'open' && title) {
|
|
895
|
-
win = `${who}${title} is ready to
|
|
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
|
|
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
|
|
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(
|
|
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\
|
|
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,
|
package/lib/gm-phone-page.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
}
|
|
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(); },
|
package/lib/known-commands.js
CHANGED
|
@@ -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
|
package/lib/member-export.js
CHANGED
|
@@ -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,
|
package/lib/memory-folder.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
if (
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
|
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();
|
package/lib/memory-live.js
CHANGED
|
@@ -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>)'),
|