atris 3.82.11 → 3.82.14

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.
@@ -0,0 +1,373 @@
1
+ 'use strict';
2
+
3
+ // atris memory show, search, read, ls, remember, write, rm, usage, log, lesson
4
+ // and agents: the person's Atris memory from any terminal, so any agent can
5
+ // read it, search it and save to it. One request per command. The login and
6
+ // network modules load only when a command needs the server.
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const live = require('../lib/memory-live');
11
+ const { NOT_LOGGED_IN, oneLine, tokenFrom } = require('../lib/developer-api');
12
+
13
+ const MAX_WRITE_BYTES = 1000000;
14
+ const STDIN_WAIT_MS = 5000;
15
+ const REMEMBER_MAX_CHARS = 1000;
16
+ const REMEMBER_FILES = ['journal', 'now', 'learning'];
17
+ const LEARNING_SECTIONS = ['want', 'understand', 'shaky'];
18
+
19
+ class UsageError extends Error {}
20
+
21
+ const enc = encodeURIComponent;
22
+
23
+ function usageFor(sub) {
24
+ const line = live.USAGE_LINES[sub];
25
+ return `${line ? `usage:\n${line}\n` : ''}All memory commands: atris memory --help`;
26
+ }
27
+
28
+ // What went wrong, in words a person or an agent can act on.
29
+ function plainError(result, auth = {}) {
30
+ const status = Number(result && result.status) || 0;
31
+ const raw = result ? result.error : null;
32
+ let detail;
33
+ if (Array.isArray(raw)) {
34
+ const first = raw[0] || {};
35
+ const field = Array.isArray(first.loc) ? first.loc.filter((part) => part !== 'body' && part !== 'query').join('.') : '';
36
+ detail = `Atris refused the request${field ? `, ${field}` : ''}: ${oneLine(first.msg || 'not valid')}`;
37
+ } else {
38
+ detail = oneLine(raw);
39
+ }
40
+ if (status === 0) return `could not reach Atris (${detail}). Check the connection and try again.`;
41
+ if (status === 401) return 'your Atris login was not accepted. Run: atris login';
42
+ if (status === 404 && detail === 'Not Found') {
43
+ return auth.scoped
44
+ ? 'this agent sign-in cannot reach Atris memory. Memory needs your own login: atris login'
45
+ : 'the Atris server does not have this memory command yet. Try again after it updates.';
46
+ }
47
+ if (status === 429) return 'Atris is getting too many requests right now. Wait a minute and try again.';
48
+ if (status >= 500) return `Atris had a problem (HTTP ${status}). Try again in a moment.`;
49
+ return detail;
50
+ }
51
+
52
+ async function defaultAuth() {
53
+ const { apiRequestJson } = require('../utils/api');
54
+ const { ensureFreshCredentials, decodeJwtClaims } = require('../utils/auth');
55
+ const creds = await ensureFreshCredentials(apiRequestJson);
56
+ const token = tokenFrom(creds);
57
+ const claims = token ? decodeJwtClaims(token) : null;
58
+ const scoped = Boolean(creds && (creds.auth_type === 'agent_client' || (claims && claims.type === 'agent_access')));
59
+ return { token, scoped };
60
+ }
61
+
62
+ function makeCall(deps, auth) {
63
+ const request = deps.apiRequestJson || require('../utils/api').apiRequestJson;
64
+ return async (pathname, options = {}) => {
65
+ const result = await request(pathname, { token: auth.token, timeoutMs: 30000, ...options });
66
+ if (result && result.ok) return result.data || {};
67
+ const error = new Error(plainError(result, auth));
68
+ error.status = result ? Number(result.status) || 0 : 0;
69
+ throw error;
70
+ };
71
+ }
72
+
73
+ // The whole of stdin, or null when nothing is piped in. Stops waiting after a
74
+ // few seconds with no data, so an agent whose stdin is an idle pipe never hangs.
75
+ function readStdin(waitMs = STDIN_WAIT_MS) {
76
+ const stdin = process.stdin;
77
+ if (stdin.isTTY) return Promise.resolve(null);
78
+ return new Promise((resolve, reject) => {
79
+ const chunks = [];
80
+ let got = false;
81
+ const timer = setTimeout(() => {
82
+ if (got) return;
83
+ stdin.pause();
84
+ resolve(null);
85
+ }, waitMs);
86
+ stdin.on('data', (chunk) => { got = true; chunks.push(chunk); });
87
+ stdin.on('end', () => { clearTimeout(timer); resolve(Buffer.concat(chunks)); });
88
+ stdin.on('error', (error) => { clearTimeout(timer); reject(error); });
89
+ });
90
+ }
91
+
92
+ const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
93
+
94
+ function agentName(ctx) {
95
+ return String(ctx.flags['--as'] || ctx.env.ATRIS_AGENT || '').trim();
96
+ }
97
+
98
+ function needAgent(ctx) {
99
+ const agent = agentName(ctx);
100
+ if (!agent) throw new UsageError('say which agent you are: --as <name>, or set ATRIS_AGENT once (like ATRIS_AGENT=codex)');
101
+ return agent;
102
+ }
103
+
104
+ function onePath(ctx, what) {
105
+ const [rel, extra] = ctx.words;
106
+ if (!rel) throw new UsageError(`name the ${what}, like wiki/people/sam.md`);
107
+ if (extra !== undefined) throw new UsageError(`give one path; "${extra}" is extra`);
108
+ return rel;
109
+ }
110
+
111
+ function numberFlag(ctx, name, read) {
112
+ const parsed = read(ctx.flags[name], name);
113
+ if (parsed.error) throw new UsageError(parsed.error);
114
+ return parsed.value;
115
+ }
116
+
117
+ const COMMANDS = {
118
+ show: {
119
+ check(ctx) {
120
+ if (ctx.words.length) throw new UsageError('show takes no words; it prints everything that matters now');
121
+ },
122
+ async run(ctx) {
123
+ const data = await ctx.call('/me/memory');
124
+ const text = live.showText(data);
125
+ return { data: { empty: Boolean(data.empty), files: data.files || {}, text }, lines: [text] };
126
+ },
127
+ },
128
+
129
+ search: {
130
+ check(ctx) {
131
+ const query = ctx.words.map((word) => word.trim()).filter(Boolean).join('|');
132
+ if (!query) throw new UsageError('say what to look for, like atris memory search "rent|lease"');
133
+ return { query, folder: ctx.flags['--in'] || '' };
134
+ },
135
+ async run(ctx, { query, folder }) {
136
+ const data = await ctx.call(`/memory/grep?q=${enc(query)}${folder ? `&folder=${enc(folder)}` : ''}`);
137
+ return { data: { query, folder, ...data }, lines: live.searchLines(query, data) };
138
+ },
139
+ },
140
+
141
+ read: {
142
+ check(ctx) {
143
+ const rel = onePath(ctx, 'file to read');
144
+ const from = numberFlag(ctx, '--from', live.positiveInt);
145
+ const count = numberFlag(ctx, '--lines', live.positiveInt);
146
+ if (ctx.flags['--raw'] && (from || count)) throw new UsageError('--raw prints the whole file; leave out --from and --lines');
147
+ return { rel, from: from || 1, count: count || 120 };
148
+ },
149
+ async run(ctx, { rel, from, count }) {
150
+ if (ctx.flags['--raw']) {
151
+ const data = await ctx.call(`/memory/file?path=${enc(rel)}`);
152
+ return { data, raw: String(data.text || '') };
153
+ }
154
+ const data = await ctx.call(`/memory/read?path=${enc(rel)}&start=${from}&count=${count}`);
155
+ return { data, lines: live.readLines({ path: rel, ...data }) };
156
+ },
157
+ },
158
+
159
+ ls: {
160
+ check(ctx) {
161
+ if (ctx.words.length > 1) throw new UsageError('give one folder, or none for the top');
162
+ return { folder: (ctx.words[0] || '').replace(/^\/+|\/+$/g, '') };
163
+ },
164
+ async run(ctx, { folder }) {
165
+ const data = await ctx.call(`/memory/ls?folder=${enc(folder)}`);
166
+ return { data: { folder, items: data.items || [] }, lines: live.lsLines(folder, data) };
167
+ },
168
+ },
169
+
170
+ remember: {
171
+ check(ctx) {
172
+ const text = ctx.words.join(' ').replace(/\s+/g, ' ').trim();
173
+ if (!text) throw new UsageError('say what to remember, like atris memory remember "prefers short answers"');
174
+ if (text.length > REMEMBER_MAX_CHARS) throw new UsageError('keep it to one short line, under 1,000 characters');
175
+ const file = ctx.flags['--to'] || 'journal';
176
+ if (!REMEMBER_FILES.includes(file)) throw new UsageError('--to is journal (the default), now or learning');
177
+ const section = ctx.flags['--section'];
178
+ if (section && file !== 'learning') throw new UsageError('--section goes with --to learning');
179
+ if (section && !LEARNING_SECTIONS.includes(section)) throw new UsageError('--section is want, understand or shaky');
180
+ const body = { text, file, source: (agentName(ctx) || 'terminal').slice(0, 40) };
181
+ if (section) body.section = section;
182
+ return body;
183
+ },
184
+ async run(ctx, body) {
185
+ const data = await ctx.call('/me/memory/remember', { method: 'POST', body, retries: 0 });
186
+ return { data, lines: [`Saved to ${data.file || body.file}: ${String(data.added || body.text).replace(/^- /, '')}`] };
187
+ },
188
+ },
189
+
190
+ write: {
191
+ check(ctx) {
192
+ const [rel, extra] = ctx.words;
193
+ if (!rel) throw new UsageError('name the page, like wiki/people/sam.md');
194
+ if (extra !== undefined) {
195
+ throw new UsageError('the page comes from --file or stdin, not from words. Pipe it in: echo "text" | atris memory write <path>');
196
+ }
197
+ return { rel, file: ctx.flags['--file'] };
198
+ },
199
+ async run(ctx, { rel, file }) {
200
+ let buffer;
201
+ let from = 'the page';
202
+ if (file && file !== '-') {
203
+ from = file;
204
+ try {
205
+ buffer = fs.readFileSync(path.resolve(ctx.cwd, file));
206
+ } catch (error) {
207
+ throw new Error(`could not read ${file}: ${error.code === 'ENOENT' ? 'no such file' : oneLine(error.message)}`);
208
+ }
209
+ } else {
210
+ buffer = await (ctx.deps.readStdin || readStdin)();
211
+ if (buffer === null) {
212
+ throw new UsageError(`give the page with --file <file>, or pipe it in: cat page.md | atris memory write ${rel}`);
213
+ }
214
+ }
215
+ if (!buffer.length) {
216
+ throw new UsageError(`nothing to save, ${from === 'the page' ? 'no text came in' : `${from} is empty`}. To delete a page, run: atris memory rm ${rel}`);
217
+ }
218
+ if (buffer.length > MAX_WRITE_BYTES) throw new Error('Keep a write under 1 MB.');
219
+ let text;
220
+ try { text = utf8.decode(buffer); } catch { throw new Error(`${from} is not text. Only text pages and files can be saved here.`); }
221
+ const data = await ctx.call('/memory/write', { method: 'PUT', body: { path: rel, text } });
222
+ return { data, lines: [`Saved ${data.path || rel} (${live.size(data.bytes != null ? data.bytes : buffer.length)}).`] };
223
+ },
224
+ },
225
+
226
+ rm: {
227
+ check(ctx) {
228
+ return { rel: onePath(ctx, 'page to delete') };
229
+ },
230
+ async run(ctx, { rel }) {
231
+ const data = await ctx.call(`/memory/file?path=${enc(rel)}`, { method: 'DELETE', retries: 0 });
232
+ return { data, lines: [`Deleted ${data.path || rel} from your Atris memory.`] };
233
+ },
234
+ },
235
+
236
+ usage: {
237
+ check(ctx) {
238
+ if (ctx.words.length) throw new UsageError('usage takes no words');
239
+ },
240
+ async run(ctx) {
241
+ const data = await ctx.call('/memory/usage');
242
+ return { data, lines: [data.message || `Your files use ${live.size(data.used_bytes)}.`] };
243
+ },
244
+ },
245
+
246
+ log: {
247
+ check(ctx) {
248
+ const agent = needAgent(ctx);
249
+ const text = ctx.words.join(' ').replace(/\s+/g, ' ').trim();
250
+ if (!text) throw new UsageError('say what you did, like atris memory log "drafted the lease email" --as codex');
251
+ return { text, agent };
252
+ },
253
+ async run(ctx, body) {
254
+ const data = await ctx.call('/memory/log', { method: 'POST', body, retries: 0 });
255
+ return { data, lines: [`Logged in ${data.path}: ${String(data.line || body.text).replace(/^- /, '')}`] };
256
+ },
257
+ },
258
+
259
+ lesson: {
260
+ check(ctx) {
261
+ const agent = needAgent(ctx);
262
+ const text = ctx.words.join(' ').replace(/\s+/g, ' ').trim();
263
+ if (!text) throw new UsageError('say the lesson, like atris memory lesson "search names first" --before 3 --after 5 --as codex');
264
+ const before = numberFlag(ctx, '--before', live.plainNumber);
265
+ const after = numberFlag(ctx, '--after', live.plainNumber);
266
+ return { text, score_before: before, score_after: after, agent };
267
+ },
268
+ async run(ctx, body) {
269
+ const data = await ctx.call('/memory/lesson', { method: 'POST', body, retries: 0 });
270
+ const score = `${body.score_before} -> ${body.score_after}`;
271
+ const lines = data.kept
272
+ ? [`Kept in ${data.path}: ${String(data.lesson || body.text).replace(/^- /, '')}`]
273
+ : [`Not kept, because the score did not go up (${score}). Logged as tried and dropped in ${data.path}.`];
274
+ return { data, lines };
275
+ },
276
+ },
277
+
278
+ agents: {
279
+ local: true,
280
+ check(ctx) {
281
+ if (ctx.words.length) throw new UsageError('agents takes no words. To add the rules to a file: --write AGENTS.md');
282
+ },
283
+ async run(ctx) {
284
+ const agent = agentName(ctx);
285
+ const block = live.rulesBlock(agent);
286
+ const apiBase = ctx.deps.apiBase || require('../utils/api').getApiBaseUrl();
287
+ const mcp = live.mcpSetup(apiBase);
288
+ const target = ctx.flags['--write'];
289
+ const lines = [];
290
+ let file = null;
291
+ if (target) {
292
+ file = live.writeRulesFile(target, block, ctx.cwd);
293
+ const shown = path.relative(ctx.cwd, file.path) || file.path;
294
+ if (file.action === 'added') lines.push(`Added the Atris memory rules to ${shown}.`);
295
+ else if (file.action === 'replaced') lines.push(`Updated the Atris memory rules in ${shown}. The rest of the file is untouched.`);
296
+ else lines.push(`${shown} already has these rules. Nothing changed.`);
297
+ } else {
298
+ lines.push(
299
+ 'Paste this into the agent\'s rules file (AGENTS.md, CLAUDE.md, .cursorrules or Codex instructions),',
300
+ 'or let atris add it and keep it current: atris memory agents --write AGENTS.md',
301
+ '',
302
+ block,
303
+ );
304
+ }
305
+ const setup = live.mcpLines(mcp);
306
+ if (setup.length) lines.push('', ...setup);
307
+ return { data: { agent: agent || null, block, file, mcp }, lines };
308
+ },
309
+ },
310
+ };
311
+ COMMANDS.setup = COMMANDS.agents;
312
+
313
+ async function memoryLiveCommand(args = [], deps = {}) {
314
+ const log = deps.log || console.log;
315
+ const err = deps.err || console.error;
316
+ const out = deps.out || ((text) => process.stdout.write(text));
317
+ const parsed = live.parseLiveArgs(args);
318
+ const json = Boolean(parsed.json) || args.includes('--json');
319
+ const sub = parsed.sub || '';
320
+
321
+ const usageFail = (message) => {
322
+ if (json) log(JSON.stringify({ ok: false, error: message }));
323
+ else err(`atris memory ${sub}: ${message}\n${usageFor(sub)}`);
324
+ return 2;
325
+ };
326
+ const fail = (message, status) => {
327
+ if (json) log(JSON.stringify({ ok: false, error: message, ...(status ? { status } : {}) }));
328
+ else err(message);
329
+ return 1;
330
+ };
331
+
332
+ if (parsed.error) return usageFail(parsed.error);
333
+ const command = COMMANDS[sub];
334
+ if (!command) return usageFail('not a memory command');
335
+ if (parsed.help) {
336
+ log(usageFor(sub));
337
+ return 0;
338
+ }
339
+
340
+ const ctx = { ...parsed, deps, env: deps.env || process.env, cwd: deps.cwd || process.cwd() };
341
+ try {
342
+ const prepared = command.check ? command.check(ctx) : undefined;
343
+ if (!command.local) {
344
+ const auth = await (deps.getAuth || defaultAuth)();
345
+ if (!auth || !auth.token) return fail(NOT_LOGGED_IN);
346
+ ctx.call = makeCall(deps, auth);
347
+ }
348
+ const result = await command.run(ctx, prepared);
349
+ if (json) log(JSON.stringify({ ok: true, ...result.data }));
350
+ else if (result.raw !== undefined) out(result.raw);
351
+ else for (const line of result.lines) log(line);
352
+ return 0;
353
+ } catch (error) {
354
+ if (error instanceof UsageError) return usageFail(error.message);
355
+ return fail(oneLine(error && error.message), error && error.status);
356
+ }
357
+ }
358
+
359
+ // Called by bin/atris.js before the rest of the CLI loads.
360
+ function runFromCli(args) {
361
+ const exit = (code) => {
362
+ if (process.stdout.writableLength === 0) process.exit(code);
363
+ else process.stdout.write('', () => process.exit(code));
364
+ };
365
+ memoryLiveCommand(args)
366
+ .then((code) => exit(typeof code === 'number' ? code : 0))
367
+ .catch((error) => {
368
+ console.error(oneLine(error && error.message ? error.message : error));
369
+ process.exit(1);
370
+ });
371
+ }
372
+
373
+ module.exports = { memoryLiveCommand, plainError, runFromCli };
@@ -0,0 +1,205 @@
1
+ 'use strict';
2
+
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const { apiRequestJson } = require('../utils/api');
6
+ const { ensureFreshCredentials } = require('../utils/auth');
7
+ const { dangerousWorkspaceReason } = require('../lib/workspace-safety');
8
+ const { NOT_LOGGED_IN, tokenFrom, oneLine } = require('../lib/developer-api');
9
+ const {
10
+ isRemovable,
11
+ isWritable,
12
+ memoryClient,
13
+ pullFolder,
14
+ pushFolder,
15
+ statusFolder,
16
+ } = require('../lib/memory-folder');
17
+ const { progressPrinter } = require('../lib/memory-files');
18
+ const { LIVE_HELP, isLiveCommand } = require('../lib/memory-live');
19
+
20
+ const USAGE = [
21
+ ...LIVE_HELP,
22
+ 'copy to laptop: atris memory <pull|push|status> [folder] [--json]',
23
+ ' pull copy your Atris memory folder down (default: ~/atris-memory)',
24
+ ' push send back changes under wiki/ and files/ (any file under files/: photos, PDFs, recordings)',
25
+ ' --prune also removes pages under wiki/ and files/ that you deleted here',
26
+ ' status show what changed here since the last pull',
27
+ ].join('\n');
28
+
29
+ function resolveDir(arg) {
30
+ const home = os.homedir();
31
+ let raw = arg || path.join(home, 'atris-memory');
32
+ if (raw === '~') raw = home;
33
+ else if (raw.startsWith('~/')) raw = path.join(home, raw.slice(2));
34
+ const abs = path.resolve(raw);
35
+ const reason = dangerousWorkspaceReason(abs);
36
+ if (reason) throw new Error(`${abs} is ${reason}. Pick a dedicated folder, like ~/atris-memory.`);
37
+ return abs;
38
+ }
39
+
40
+ function shortPath(abs) {
41
+ const home = os.homedir();
42
+ return abs === home || abs.startsWith(home + path.sep) ? `~${abs.slice(home.length)}` : abs;
43
+ }
44
+
45
+ function plural(n, one, many) {
46
+ return `${n} ${n === 1 ? one : many}`;
47
+ }
48
+
49
+ function nameList(list, max = 4) {
50
+ const names = list.map((item) => (typeof item === 'string' ? item : item.path));
51
+ return names.length > max ? `${names.slice(0, max).join(', ')} and ${names.length - max} more` : names.join(', ');
52
+ }
53
+
54
+ function pullLines(dir, r) {
55
+ const lines = [`Pulled ${plural(r.total, 'file', 'files')} into ${shortPath(dir)}.`];
56
+ const parts = [];
57
+ if (r.added.length) parts.push(`${r.added.length} new`);
58
+ if (r.updated.length) parts.push(`${r.updated.length} updated`);
59
+ if (r.same) parts.push(`${r.same} already current`);
60
+ if (parts.length) lines.push(`${parts.join(', ')}.`);
61
+ if (r.keptLocal.length) lines.push(`Kept your edits to ${nameList(r.keptLocal)}. The server copy has not changed.`);
62
+ if (r.conflicts.length) {
63
+ lines.push(`Left ${nameList(r.conflicts)} alone: it changed here and on the server. To take the server copy, move yours aside and pull again.`);
64
+ }
65
+ if (r.unsafe.length) lines.push(`Skipped ${plural(r.unsafe.length, 'file', 'files')} with names that are not safe to copy.`);
66
+ if (r.gone) lines.push(`${plural(r.gone, 'file is', 'files are')} no longer on the server. Your copies are untouched.`);
67
+ for (const f of r.failed.slice(0, 3)) lines.push(`Could not copy ${f.path}: ${oneLine(f.error)}`);
68
+ if (r.failed.length > 3) lines.push(`${r.failed.length - 3} more could not be copied.`);
69
+ if (r.usedLineFallback) lines.push('The server is on its older download, so line endings may be tidied.');
70
+ return lines;
71
+ }
72
+
73
+ function pushLines(r) {
74
+ const lines = [];
75
+ if (r.pushed.length) lines.push(`Pushed ${plural(r.pushed.length, 'file', 'files')}: ${nameList(r.pushed)}.`);
76
+ if (r.alreadyThere.length) lines.push(`Already on the server: ${nameList(r.alreadyThere)}.`);
77
+ if (r.notPushable.length) {
78
+ lines.push(`Not pushed: ${nameList(r.notPushable)}. Those change through remember (the Atris connection's remember tool), not by file write.`);
79
+ }
80
+ if (r.conflicts.length) {
81
+ lines.push(`Not pushed: ${nameList(r.conflicts)} changed on the server since your last pull, so nothing was overwritten. Run: atris memory pull`);
82
+ }
83
+ if (r.tooBig.length) lines.push(`Not pushed: ${nameList(r.tooBig)} is over 1 MB.`);
84
+ if (r.notText.length) lines.push(`Not pushed: ${nameList(r.notText)} is not a text file.`);
85
+ for (const f of r.failed.slice(0, 3)) lines.push(`Could not ${f.removing ? 'remove' : 'push'} ${f.path}: ${oneLine(f.error)}`);
86
+ lines.push(...deleteLines(r));
87
+ if (r.skipped.length) lines.push(`Skipped ${nameList(r.skipped.map((s) => `${s.path} (${s.reason})`))}.`);
88
+ if (!lines.length) lines.push('Nothing to push. Nothing changed since your last pull.');
89
+ return lines;
90
+ }
91
+
92
+ const PRUNE_HINT = 'atris memory push --prune';
93
+
94
+ function deleteLines(r) {
95
+ const lines = [];
96
+ if (r.pruned) {
97
+ if (r.removed.length) lines.push(`Removed ${plural(r.removed.length, 'file', 'files')} from your Atris memory: ${nameList(r.removed)}.`);
98
+ if (r.alreadyGone.length) lines.push(`Already gone from your Atris memory: ${nameList(r.alreadyGone)}.`);
99
+ if (r.changedOnServer.length) {
100
+ lines.push(`Not removed: ${nameList(r.changedOnServer)} changed on the server since your last pull, so nothing was lost. Run: atris memory pull`);
101
+ }
102
+ if (r.notRemovable.length) lines.push(`Not removed: ${nameList(r.notRemovable)}. Notes and daily pages can't be deleted; pull brings them back.`);
103
+ return lines;
104
+ }
105
+ const removable = r.deletedHere.filter(isRemovable);
106
+ const kept = r.deletedHere.filter((rel) => !isRemovable(rel));
107
+ if (removable.length) {
108
+ lines.push(`${plural(removable.length, 'file was', 'files were')} deleted here (${nameList(removable)}) and ${removable.length === 1 ? 'is' : 'are'} still in your Atris memory. To remove ${removable.length === 1 ? 'it' : 'them'} there too, run: ${PRUNE_HINT}`);
109
+ }
110
+ if (kept.length) lines.push(`${nameList(kept)} ${kept.length === 1 ? 'was' : 'were'} deleted here. Notes and daily pages can't be deleted; pull brings them back.`);
111
+ return lines;
112
+ }
113
+
114
+ function pruneNotice(log) {
115
+ return (doomed) => {
116
+ log(`Removing ${plural(doomed.length, 'file', 'files')} from your Atris memory, because you deleted ${doomed.length === 1 ? 'it' : 'them'} here:`);
117
+ for (const rel of doomed) log(` ${rel}`);
118
+ };
119
+ }
120
+
121
+ function statusLines(r) {
122
+ const rows = [
123
+ ...r.modified.map((rel) => ['edited', rel]),
124
+ ...r.added.map((rel) => ['new', rel]),
125
+ ...r.deleted.map((rel) => ['deleted', rel]),
126
+ ];
127
+ const when = r.pulledAt ? ` (last pull ${r.pulledAt})` : '';
128
+ if (!rows.length) return [`Nothing changed here since your last pull${when}.`];
129
+ const lines = [`Changed here since your last pull${when}:`];
130
+ for (const [kind, rel] of rows) {
131
+ const note = kind === 'deleted'
132
+ ? (isRemovable(rel) ? ` (${PRUNE_HINT} removes it from Atris)` : ' (notes and daily pages stay; pull brings it back)')
133
+ : isWritable(rel) ? '' : ' (changes through remember, not pushed)';
134
+ lines.push(` ${kind.padEnd(8)} ${rel}${note}`);
135
+ }
136
+ for (const s of r.skipped) lines.push(` skipped ${s.path} (${s.reason})`);
137
+ return lines;
138
+ }
139
+
140
+ async function defaultToken() {
141
+ const creds = await ensureFreshCredentials(apiRequestJson);
142
+ return { token: tokenFrom(creds), account: (creds && creds.user_id) || null };
143
+ }
144
+
145
+ async function memoryCommand(args = [], deps = {}) {
146
+ const log = deps.log || console.log;
147
+ const err = deps.err || console.error;
148
+ const json = args.includes('--json');
149
+ const prune = args.includes('--prune');
150
+ const words = args.filter((arg) => !arg.startsWith('-'));
151
+ const [sub, folderArg] = words;
152
+ if (isLiveCommand(sub)) return require('./memory-live').memoryLiveCommand(args, deps);
153
+ const help = args.includes('--help') || args.includes('-h') || sub === 'help' || !sub;
154
+ if (help) {
155
+ log(USAGE);
156
+ return 0;
157
+ }
158
+ if (!['pull', 'push', 'status'].includes(sub)) {
159
+ err(`atris memory: unknown command "${sub}"\n${USAGE}`);
160
+ return 2;
161
+ }
162
+ if (prune && sub !== 'push') {
163
+ err(`atris memory: --prune only works with push\n${USAGE}`);
164
+ return 2;
165
+ }
166
+
167
+ const fail = (message) => {
168
+ if (json) log(JSON.stringify({ ok: false, error: oneLine(message) }));
169
+ else err(oneLine(message));
170
+ return 1;
171
+ };
172
+
173
+ try {
174
+ const dir = resolveDir(folderArg);
175
+ let result;
176
+ let lines;
177
+ let code = 0;
178
+ if (sub === 'status') {
179
+ result = statusFolder({ dir });
180
+ lines = statusLines(result);
181
+ } else {
182
+ const { token, account } = await (deps.getToken || defaultToken)();
183
+ if (!token) return fail(NOT_LOGGED_IN);
184
+ const client = memoryClient(deps.apiRequestJson || apiRequestJson, token);
185
+ const tty = deps.tty !== undefined ? deps.tty : (process.stderr.isTTY ? process.stderr : null);
186
+ const onProgress = json ? null : progressPrinter({ log, tty });
187
+ if (sub === 'pull') {
188
+ result = await pullFolder({ dir, client, account, onProgress });
189
+ lines = pullLines(dir, result);
190
+ code = result.failed.length ? 1 : 0;
191
+ } else {
192
+ result = await pushFolder({ dir, client, account, prune, onPrune: json ? null : pruneNotice(log), onProgress });
193
+ lines = pushLines(result);
194
+ code = result.failed.length || result.conflicts.length || (result.changedOnServer || []).length ? 1 : 0;
195
+ }
196
+ }
197
+ if (json) log(JSON.stringify({ ok: code === 0, dir, ...result }));
198
+ else for (const line of lines) log(line);
199
+ return code;
200
+ } catch (error) {
201
+ return fail(error && error.message);
202
+ }
203
+ }
204
+
205
+ module.exports = { memoryCommand };
@@ -715,12 +715,12 @@ function runVerifyCommandCached(verify, workspaceRoot, cache = null) {
715
715
  // root: one scan covers every task in a sweep.
716
716
  const repoHygieneCache = new Map();
717
717
 
718
- function repoHygieneGate(workspaceRoot) {
718
+ function repoHygieneGate(workspaceRoot, skillScope = null) {
719
719
  const root = path.resolve(workspaceRoot || process.cwd());
720
720
  // Skill receipts are mutable workspace state, so recompute this gate before
721
721
  // consulting the repo-wide dead-code cache. A new receipt can unblock a
722
722
  // landing, while a later SKILL.md edit must make an older receipt stale.
723
- const skillEval = skillEvalGate(root);
723
+ const skillEval = skillEvalGate(root, skillScope || {});
724
724
  if (!skillEval.ok) return skillEval;
725
725
  if (repoHygieneCache.has(root)) return repoHygieneCache.get(root);
726
726
  let result = { ok: true, skipped: true };
@@ -755,8 +755,17 @@ function repoHygieneGate(workspaceRoot) {
755
755
 
756
756
  // Shared by both landing lanes so the check cannot ship in one dispatch branch
757
757
  // and silently skip the sibling (lesson: parallel-paths-drift).
758
- function hygieneBlockResult(task, ref) {
759
- const hygiene = repoHygieneGate(task.workspace_root || process.cwd());
758
+ // A job answers for the skills it changed. When its exact file list is on
759
+ // record (the same list the taste check just read), only those files count,
760
+ // read where the job's branch is checked out. With no list on record the
761
+ // workspace-wide check stays, so an unevaluated skill is never waved through.
762
+ function jobSkillScope(task, candidateGate) {
763
+ if (!candidateGate || candidateGate.advisory_only || !Array.isArray(candidateGate.paths)) return null;
764
+ return { changeRoot: candidateWorkspaceRoot(task), paths: candidateGate.paths };
765
+ }
766
+
767
+ function hygieneBlockResult(task, ref, candidateGate = null) {
768
+ const hygiene = repoHygieneGate(task.workspace_root || process.cwd(), jobSkillScope(task, candidateGate));
760
769
  if (hygiene.ok) return null;
761
770
  return {
762
771
  eligible: false,
@@ -1360,7 +1369,7 @@ function evaluateAutoAccept(task, options = {}) {
1360
1369
  if (executeVerify) {
1361
1370
  candidateGate = candidatePolicyGate(task, { verifyCache, executeDetectors: true });
1362
1371
  if (!candidateGate.ok) return { ...candidateGate, ref };
1363
- const hygieneBlock = hygieneBlockResult(task, ref);
1372
+ const hygieneBlock = hygieneBlockResult(task, ref, candidateGate.gate);
1364
1373
  if (hygieneBlock) return hygieneBlock;
1365
1374
  }
1366
1375
  return {
@@ -1444,7 +1453,7 @@ function evaluateAutoAccept(task, options = {}) {
1444
1453
  if (executeVerify) {
1445
1454
  candidateGate = candidatePolicyGate(task, { verifyCache, executeDetectors: true });
1446
1455
  if (!candidateGate.ok) return { ...candidateGate, ref };
1447
- const hygieneBlock = hygieneBlockResult(task, ref);
1456
+ const hygieneBlock = hygieneBlockResult(task, ref, candidateGate.gate);
1448
1457
  if (hygieneBlock) return hygieneBlock;
1449
1458
  }
1450
1459
 
@@ -3,8 +3,8 @@
3
3
  const knownCommands = ['init', 'guide', 'doctor', 'doc-health', 'install', 'workspace', 'log', 'logs', 'later', 'idea', 'done', 'wish', 'ask', 'approve', 'stop', 'ready', 'check', 'drill', 'dream', 'now', '99', 'goal', 'wtf', 'founder', 'orb', 'radar', 'who', 'stream', 'ctop', 'launchpad', 'status', 'analytics', 'revisions', 'visualize', 'brain', 'brainstorm', 'autopilot', 'run', 'plan', 'do', 'review', 'release',
4
4
  'activate', '_activate', 'agent', 'team', 'chat', 'fast', 'ax', 'console', 'serve', 'login', 'logout', 'whoami', 'switch', 'use', 'accounts', '_resolve', '_profile-email', '_switch-session', 'shell-init', 'update', 'upgrade', 'version', 'help', 'next', 'atris',
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
- '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', 'scorecard', 'rating', 'sync-checkout', 'zen',
7
- 'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'balance', 'usage', 'spend', 'api-key', 'agent-client', 'topup', 'buy', 'transactions', 'hire', 'hires', 'mail', 'design', 'mcp', 'gmail', 'google', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
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
+ 'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'memory', 'balance', 'usage', 'spend', 'api-key', 'agent-client', 'topup', 'buy', 'transactions', 'hire', 'hires', 'mail', 'design', 'mcp', 'gmail', 'google', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
8
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'];
9
9
 
10
10
  // Damerau-Levenshtein edit distance between two short strings. Counts an