atris 3.63.0 → 3.65.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/README.md +4 -0
- package/atris/skills/commerce/SKILL.md +24 -0
- package/atris/skills/x-search/SKILL.md +3 -1
- package/ax +4 -3
- package/bin/atris.js +28 -2
- package/cli/atris_code.py +25 -1
- package/commands/agent-spawn.js +1 -0
- package/commands/auth.js +79 -10
- package/commands/autoland.js +11 -1
- package/commands/browse.js +2 -2
- package/commands/business.js +3 -3
- package/commands/buy.js +3 -3
- package/commands/deck.js +2 -2
- package/commands/engine.js +292 -65
- package/commands/feedback.js +2 -2
- package/commands/fork.js +2 -2
- package/commands/gm.js +2 -1
- package/commands/hire.js +189 -0
- package/commands/improve.js +142 -8
- package/commands/integrations.js +4 -3
- package/commands/loops.js +2 -1
- package/commands/mail.js +163 -0
- package/commands/member.js +47 -9
- package/commands/mission.js +4 -3
- package/commands/pack-craft.js +2 -2
- package/commands/publish.js +2 -2
- package/commands/radar.js +3 -2
- package/commands/router.js +2 -2
- package/commands/stream.js +22 -1
- package/commands/task.js +84 -2
- package/commands/team.js +355 -67
- package/commands/transactions.js +3 -3
- package/commands/truth.js +2 -2
- package/commands/x-search.js +81 -10
- package/commands/youtube.js +2 -1
- package/lib/auto-accept-certified.js +83 -9
- package/lib/autoland.js +3 -2
- package/lib/ax-auto-lane.js +2 -2
- package/lib/brief-rules.js +11 -0
- package/lib/business-target.js +131 -0
- package/lib/chat-log-scan.js +2 -1
- package/lib/ci-runner.js +3 -2
- package/lib/codex-flight.js +2 -1
- package/lib/commerce.js +17 -2
- package/lib/deck-history.js +2 -2
- package/lib/deck-review.js +3 -3
- package/lib/design-api.js +2 -2
- package/lib/engine-processes.js +430 -0
- package/lib/engine-registry.js +101 -21
- package/lib/first-minute.js +2 -2
- package/lib/fleet.js +8 -36
- package/lib/git-spawn.js +5 -2
- package/lib/ignored-cache.js +51 -0
- package/lib/known-commands.js +1 -1
- package/lib/manifest.js +2 -2
- package/lib/map-refs.js +70 -4
- package/lib/member-identity.js +56 -0
- package/lib/pack-capabilities.js +2 -1
- package/lib/permission-grants.js +2 -2
- package/lib/pulse.js +5 -5
- package/lib/roster-models.js +11 -0
- package/lib/roster-runs.js +13 -2
- package/lib/roster-stale.js +241 -0
- package/lib/runner-command.js +22 -6
- package/lib/sandbox-pids.js +113 -0
- package/lib/secret-gateway.js +3 -13
- package/lib/state-home.js +106 -0
- package/lib/task-db.js +4 -3
- package/lib/task-prune.js +462 -0
- package/lib/team-lineup.js +10 -71
- package/lib/team-presence.js +224 -0
- package/lib/text-table.js +81 -0
- package/lib/x-post-check.js +497 -0
- package/package.json +1 -1
- package/scripts/det/data/xsearch-answer.json +24 -0
- package/scripts/det/data/xsearch-live-2026-09-29.json +93 -0
- package/scripts/det/xsearch-bench.js +307 -0
- package/scripts/det/ytnotes +61 -15
- package/scripts/det/ytrail-bench.js +2 -1
- package/utils/auth.js +3 -3
- package/utils/update-check.js +2 -2
package/commands/hire.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// atris hire "<name>" --job "<idea>" one call: member, inbox, allowance, nightly loop
|
|
4
|
+
// atris hires the morning check-in: one line per hire, then its 24h / 7d scoreboard
|
|
5
|
+
// Backend: POST /api/business/{id}/hire, GET /api/business/{id}/hires
|
|
6
|
+
|
|
7
|
+
const { apiRequestJson } = require('../utils/api');
|
|
8
|
+
const { NOT_LOGGED_IN } = require('../lib/developer-api');
|
|
9
|
+
const {
|
|
10
|
+
parseArgs, businessOrSay, loginToken, errorText, relativeTime, dollars, clip,
|
|
11
|
+
} = require('../lib/business-target');
|
|
12
|
+
|
|
13
|
+
const HIRE_FLAGS = { switches: ['outreach'], values: ['job', 'budget', 'schedule', 'timezone'] };
|
|
14
|
+
|
|
15
|
+
function hireHelp(log = console.log) {
|
|
16
|
+
log(`usage: atris hire "<name>" --job "<idea>" [--budget 50] [--outreach] [--schedule nightly] [--business <slug>] [--json]
|
|
17
|
+
|
|
18
|
+
hire an agent in one step. it gets a name, a job, its own email inbox,
|
|
19
|
+
a monthly spending allowance, and a work loop that runs overnight.
|
|
20
|
+
|
|
21
|
+
--job the idea it works on, in plain words (required)
|
|
22
|
+
--budget most it may ask you to approve per month, in dollars (default 0 = no spending)
|
|
23
|
+
you still approve every purchase yourself
|
|
24
|
+
--outreach mark it as doing outreach (recorded; it uses a normal inbox for now)
|
|
25
|
+
--schedule nightly (default, 2 AM your time), hourly, or a cron like "0 3 * * *"
|
|
26
|
+
--timezone defaults to this computer's timezone
|
|
27
|
+
--business which business; defaults to the business folder you are in
|
|
28
|
+
|
|
29
|
+
running it again with the same name is safe: you get the same hire back,
|
|
30
|
+
and anything missing (like the inbox) is filled in. --budget changes the allowance.
|
|
31
|
+
|
|
32
|
+
then each morning: atris hires`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function hiresHelp(log = console.log) {
|
|
36
|
+
log(`usage: atris hires [--business <slug>] [--json]
|
|
37
|
+
|
|
38
|
+
the morning check-in: one line per hire with its email in the last day,
|
|
39
|
+
what it spent this month, and what its last overnight run did. under it,
|
|
40
|
+
its scoreboard for the last 24 hours and 7 days: emails sent, how many got
|
|
41
|
+
a reply from a person, bounces, and spend.`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function localTimezone() {
|
|
45
|
+
try {
|
|
46
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
|
47
|
+
} catch {
|
|
48
|
+
return '';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function renderHire(data = {}, now = Date.now()) {
|
|
53
|
+
const m = data.member || {};
|
|
54
|
+
const lines = [data.created ? `Hired ${m.name}.` : `${m.name} was already hired. Same hire, nothing duplicated.`];
|
|
55
|
+
if (m.job) lines.push(`Job: ${clip(m.job, 200)}`);
|
|
56
|
+
if (data.inbox && data.inbox.address) lines.push(`Inbox: ${data.inbox.address}`);
|
|
57
|
+
const a = data.allowance || {};
|
|
58
|
+
lines.push(Number(a.monthly_usd) > 0
|
|
59
|
+
? `Allowance: ${dollars(a.monthly_usd)} a month. You still approve every purchase.`
|
|
60
|
+
: 'Allowance: none. It cannot ask to spend money.');
|
|
61
|
+
if (a.note) lines.push(` ${a.note}`);
|
|
62
|
+
const s = data.schedule || {};
|
|
63
|
+
let when = s.label ? `Works ${s.label}.` : 'Works on its schedule.';
|
|
64
|
+
if (s.enabled === false) when += ' Asleep right now.';
|
|
65
|
+
else if (data.next_run_at) when += ` First run ${relativeTime(data.next_run_at, now)}.`;
|
|
66
|
+
lines.push(when);
|
|
67
|
+
if (data.outreach && data.outreach.requested) lines.push(`Outreach: ${data.outreach.note || 'recorded.'}`);
|
|
68
|
+
lines.push('', 'Check in each morning: atris hires');
|
|
69
|
+
return lines.join('\n');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function hireLine(h = {}, now = Date.now()) {
|
|
73
|
+
const parts = [];
|
|
74
|
+
if (h.status && h.status !== 'active') {
|
|
75
|
+
const why = { needs_inbox: 'paused, no inbox yet', needs_allowance: 'paused, allowance not set up', setting_up: 'still setting up',
|
|
76
|
+
pending_schedule: 'set up, nightly work not switched on yet (run the same hire again)' };
|
|
77
|
+
parts.push(why[h.status] || `paused (${h.status})`);
|
|
78
|
+
}
|
|
79
|
+
const mail = h.emails_last_24h;
|
|
80
|
+
if (mail) parts.push(`sent ${mail.sent}, got ${mail.received} emails today`);
|
|
81
|
+
else parts.push('no inbox');
|
|
82
|
+
const a = h.allowance || {};
|
|
83
|
+
if (Number(a.monthly_usd) > 0) {
|
|
84
|
+
parts.push(a.spent_this_month_usd == null
|
|
85
|
+
? `${dollars(a.monthly_usd)} a month`
|
|
86
|
+
: `spent ${dollars(a.spent_this_month_usd)} of ${dollars(a.monthly_usd)}`);
|
|
87
|
+
}
|
|
88
|
+
if (h.last_run_at) {
|
|
89
|
+
const result = h.last_result ? `: ${clip(h.last_result, 80)}` : '';
|
|
90
|
+
parts.push(`last ran ${relativeTime(h.last_run_at, now)}${result}`);
|
|
91
|
+
} else if (h.schedule && h.schedule.enabled === false) {
|
|
92
|
+
parts.push('asleep, has not run');
|
|
93
|
+
} else if (h.next_run_at) {
|
|
94
|
+
parts.push(`first run ${relativeTime(h.next_run_at, now)}`);
|
|
95
|
+
} else {
|
|
96
|
+
parts.push('has not run yet');
|
|
97
|
+
}
|
|
98
|
+
return `${h.name}: ${parts.join(' · ')}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function windowText(label, w) {
|
|
102
|
+
if (!w || w.sent == null) return '';
|
|
103
|
+
const replies = Number(w.replies) || 0;
|
|
104
|
+
const value = w.reply_rate == null || w.reply_rate === '' ? NaN : Number(w.reply_rate);
|
|
105
|
+
const rate = Number.isFinite(value) ? ` (${Math.round(value * 100)}%)` : '';
|
|
106
|
+
const parts = [`sent ${w.sent}`, `${replies} ${replies === 1 ? 'reply' : 'replies'}${rate}`,
|
|
107
|
+
`${Number(w.bounces) || 0} bounced`];
|
|
108
|
+
if (w.spend_cents != null) parts.push(`spent ${dollars(Number(w.spend_cents) / 100)}`);
|
|
109
|
+
return `${label}: ${parts.join(', ')}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The second line under a hire: how its mail is landing. Empty for an older backend.
|
|
113
|
+
function scoreLine(board, now = Date.now()) {
|
|
114
|
+
if (!board) return '';
|
|
115
|
+
const parts = [windowText('last 24h', board.last_24h), windowText('last 7 days', board.last_7d)].filter(Boolean);
|
|
116
|
+
if (!parts.length) return '';
|
|
117
|
+
if (board.last_action_at) parts.push(`last action ${relativeTime(board.last_action_at, now)}`);
|
|
118
|
+
if (board.capped) parts.push('counts cover only the newest mail');
|
|
119
|
+
return ` ${parts.join(' · ')}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function renderHires(data = {}, now = Date.now()) {
|
|
123
|
+
const rows = Array.isArray(data.hires) ? data.hires : [];
|
|
124
|
+
if (!rows.length) return 'No hires yet. Start one: atris hire "Maya" --job "grow the newsletter"';
|
|
125
|
+
return rows.map((h) => [hireLine(h, now), scoreLine(h.scoreboard, now)].filter(Boolean).join('\n')).join('\n');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function io(deps) {
|
|
129
|
+
return { log: deps.log || console.log, err: deps.err || console.error };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function hireCommand(args = [], deps = {}) {
|
|
133
|
+
const out = io(deps);
|
|
134
|
+
const { flags, pos, error } = parseArgs(args, HIRE_FLAGS);
|
|
135
|
+
if (flags.help || pos[0] === 'help') { hireHelp(out.log); return 0; }
|
|
136
|
+
if (error) { out.err(`${error}. see: atris hire --help`); return 1; }
|
|
137
|
+
|
|
138
|
+
const name = pos.join(' ').trim();
|
|
139
|
+
const job = typeof flags.job === 'string' ? flags.job.trim() : '';
|
|
140
|
+
if (!name) { out.err('give the hire a name: atris hire "Maya" --job "<idea>"'); return 1; }
|
|
141
|
+
if (!job) { out.err('give the hire a job: --job "<the idea to grow>"'); return 1; }
|
|
142
|
+
const body = { name, job, outreach: flags.outreach === true };
|
|
143
|
+
if (flags.budget != null) {
|
|
144
|
+
const budget = Number(flags.budget);
|
|
145
|
+
if (!Number.isInteger(budget) || budget < 0 || budget > 1000) {
|
|
146
|
+
out.err('--budget must be whole dollars from 0 to 1000');
|
|
147
|
+
return 1;
|
|
148
|
+
}
|
|
149
|
+
body.monthly_allowance_usd = budget;
|
|
150
|
+
}
|
|
151
|
+
if (typeof flags.schedule === 'string') body.schedule = flags.schedule;
|
|
152
|
+
const tz = typeof flags.timezone === 'string' ? flags.timezone : (deps.timezone || localTimezone());
|
|
153
|
+
if (tz) body.timezone = tz;
|
|
154
|
+
|
|
155
|
+
const business = businessOrSay(flags, deps, out.err);
|
|
156
|
+
if (!business) return 1;
|
|
157
|
+
const token = await loginToken(deps);
|
|
158
|
+
if (!token) { out.err(NOT_LOGGED_IN); return 1; }
|
|
159
|
+
|
|
160
|
+
const request = deps.apiRequestJson || apiRequestJson;
|
|
161
|
+
const result = await request(`/business/${encodeURIComponent(business.id)}/hire`, {
|
|
162
|
+
method: 'POST', token, body, timeoutMs: 60000, retries: 0,
|
|
163
|
+
});
|
|
164
|
+
if (!result || !result.ok) { out.err(`Could not hire ${name}: ${errorText(result)}`); return 1; }
|
|
165
|
+
if (flags.json) out.log(JSON.stringify(result.data));
|
|
166
|
+
else out.log(renderHire(result.data, deps.now ? deps.now() : Date.now()));
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function hiresCommand(args = [], deps = {}) {
|
|
171
|
+
const out = io(deps);
|
|
172
|
+
const { flags, pos, error } = parseArgs(args, {});
|
|
173
|
+
if (flags.help || pos[0] === 'help') { hiresHelp(out.log); return 0; }
|
|
174
|
+
if (error) { out.err(`${error}. see: atris hires --help`); return 1; }
|
|
175
|
+
if (pos.length) { out.err(`atris hires takes no other words (got "${pos[0]}"). see: atris hires --help`); return 1; }
|
|
176
|
+
const business = businessOrSay(flags, deps, out.err);
|
|
177
|
+
if (!business) return 1;
|
|
178
|
+
const token = await loginToken(deps);
|
|
179
|
+
if (!token) { out.err(NOT_LOGGED_IN); return 1; }
|
|
180
|
+
|
|
181
|
+
const request = deps.apiRequestJson || apiRequestJson;
|
|
182
|
+
const result = await request(`/business/${encodeURIComponent(business.id)}/hires`, { method: 'GET', token });
|
|
183
|
+
if (!result || !result.ok) { out.err(`Could not load hires: ${errorText(result)}`); return 1; }
|
|
184
|
+
if (flags.json) out.log(JSON.stringify(result.data));
|
|
185
|
+
else out.log(renderHires(result.data, deps.now ? deps.now() : Date.now()));
|
|
186
|
+
return 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
module.exports = { hireCommand, hiresCommand, renderHire, renderHires, hireLine, scoreLine };
|
package/commands/improve.js
CHANGED
|
@@ -18,10 +18,13 @@
|
|
|
18
18
|
* fallback, and scorecard writes can all be faked in tests.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
const crypto = require('crypto');
|
|
21
22
|
const fs = require('fs');
|
|
22
23
|
const os = require('os');
|
|
23
24
|
const path = require('path');
|
|
24
25
|
const { spawnSync } = require('child_process');
|
|
26
|
+
const gitSpawn = require('../lib/git-spawn');
|
|
27
|
+
const ignoredCache = require('../lib/ignored-cache');
|
|
25
28
|
const { apiRequestJson, getApiBaseUrl } = require('../utils/api');
|
|
26
29
|
const { loadCredentials } = require('../utils/auth');
|
|
27
30
|
const pulse = require('../lib/pulse');
|
|
@@ -1085,8 +1088,9 @@ function parseRevisionsArgs(argv = []) {
|
|
|
1085
1088
|
return opts;
|
|
1086
1089
|
}
|
|
1087
1090
|
|
|
1088
|
-
|
|
1089
|
-
|
|
1091
|
+
// Through the module object so tests can count git spawns with a spy.
|
|
1092
|
+
function gitLines(cwdRoot, gitArgs, { input, maxBuffer } = {}) {
|
|
1093
|
+
const r = gitSpawn.runGit(gitArgs, { cwd: cwdRoot, check: false, input, maxBuffer });
|
|
1090
1094
|
if (r.status !== 0) {
|
|
1091
1095
|
const err = new Error(String(r.stderr || `git ${gitArgs[0]} exited ${r.status}`).trim());
|
|
1092
1096
|
err.gitFailed = true;
|
|
@@ -1095,17 +1099,129 @@ function gitLines(cwdRoot, gitArgs) {
|
|
|
1095
1099
|
return String(r.stdout || '');
|
|
1096
1100
|
}
|
|
1097
1101
|
|
|
1102
|
+
// File names come back NUL-separated and never quoted, whatever core.quotePath
|
|
1103
|
+
// says, so a name reads the same fresh from git and from the saved cache.
|
|
1104
|
+
const NAME_ARGS = ['-c', 'core.quotePath=false'];
|
|
1105
|
+
|
|
1106
|
+
function splitNames(out) {
|
|
1107
|
+
return String(out || '').split('\0').filter(Boolean);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1098
1110
|
/**
|
|
1099
1111
|
* Files changed by one commit. Merge commits are attributed by their
|
|
1100
1112
|
* first-parent diff (what the merge actually brought onto the mainline);
|
|
1101
1113
|
* plain commits use diff-tree. Root commits list their initial files.
|
|
1114
|
+
* The rename choice is spelled out so a user's diff.renames setting cannot
|
|
1115
|
+
* change the lists: merges find renames (git diff's default), plain commits
|
|
1116
|
+
* do not (diff-tree's default).
|
|
1102
1117
|
*/
|
|
1103
1118
|
function commitFiles(cwdRoot, commit) {
|
|
1104
1119
|
const parents = commit.parents;
|
|
1105
1120
|
const out = parents.length >= 2
|
|
1106
|
-
? gitLines(cwdRoot, ['diff', '--name-only', `${commit.hash}^1`, commit.hash])
|
|
1107
|
-
: gitLines(cwdRoot, ['diff-tree', '--no-commit-id', '--name-only', '-r', '--root', commit.hash]);
|
|
1108
|
-
return out
|
|
1121
|
+
? gitLines(cwdRoot, [...NAME_ARGS, 'diff', '--name-only', '--find-renames', '-z', `${commit.hash}^1`, commit.hash])
|
|
1122
|
+
: gitLines(cwdRoot, [...NAME_ARGS, 'diff-tree', '--no-commit-id', '--name-only', '--no-renames', '-z', '-r', '--root', commit.hash]);
|
|
1123
|
+
return splitNames(out);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/**
|
|
1127
|
+
* Files changed by many plain commits in one git process: the same diff-tree
|
|
1128
|
+
* per-commit call above, fed every hash on stdin. --always prints the hash
|
|
1129
|
+
* even for an empty commit. Raw output frames every file as a ':'-led status
|
|
1130
|
+
* field followed by its path, so a path is never mistaken for a commit hash,
|
|
1131
|
+
* even a path spelled exactly like the next one.
|
|
1132
|
+
*/
|
|
1133
|
+
function plainCommitFiles(cwdRoot, commits) {
|
|
1134
|
+
const found = new Map(commits.map((commit) => [commit.hash, []]));
|
|
1135
|
+
if (!commits.length) return found;
|
|
1136
|
+
const hashes = commits.map((commit) => commit.hash);
|
|
1137
|
+
const out = gitLines(cwdRoot, [...NAME_ARGS, 'diff-tree', '--stdin', '--always', '--raw', '--no-renames', '-z', '-r', '--root'], {
|
|
1138
|
+
input: `${hashes.join('\n')}\n`,
|
|
1139
|
+
// Many commits' lists in one reply; the per-commit calls each had 1MB.
|
|
1140
|
+
maxBuffer: Math.max(1, hashes.length) * 1024 * 1024,
|
|
1141
|
+
});
|
|
1142
|
+
const fields = splitNames(out);
|
|
1143
|
+
let current = null;
|
|
1144
|
+
for (let i = 0; i < fields.length; i++) {
|
|
1145
|
+
const field = fields[i];
|
|
1146
|
+
if (field.startsWith(':')) {
|
|
1147
|
+
// Status field; with --no-renames exactly one path follows it.
|
|
1148
|
+
if (current && i + 1 < fields.length) current.push(fields[i + 1]);
|
|
1149
|
+
i += 1;
|
|
1150
|
+
} else {
|
|
1151
|
+
current = found.get(field) || null;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
return found;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// Where git keeps state shared by every checkout of this repo (a linked
|
|
1158
|
+
// worktree points at its main repo). null when it cannot be found on disk.
|
|
1159
|
+
function gitCommonDir(root) {
|
|
1160
|
+
if (process.env.GIT_DIR || process.env.GIT_COMMON_DIR) return null;
|
|
1161
|
+
let dir = path.resolve(root);
|
|
1162
|
+
for (;;) {
|
|
1163
|
+
const dotGit = path.join(dir, '.git');
|
|
1164
|
+
let stat = null;
|
|
1165
|
+
try { stat = fs.statSync(dotGit); } catch { stat = null; }
|
|
1166
|
+
if (stat && stat.isDirectory()) return dotGit;
|
|
1167
|
+
if (stat && stat.isFile()) {
|
|
1168
|
+
const match = fs.readFileSync(dotGit, 'utf8').match(/^gitdir:\s*(.+)$/m);
|
|
1169
|
+
if (!match) return null;
|
|
1170
|
+
const gitDir = path.resolve(dir, match[1].trim());
|
|
1171
|
+
try {
|
|
1172
|
+
return path.resolve(gitDir, fs.readFileSync(path.join(gitDir, 'commondir'), 'utf8').trim());
|
|
1173
|
+
} catch {
|
|
1174
|
+
return gitDir;
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
const parent = path.dirname(dir);
|
|
1178
|
+
if (parent === dir) return null;
|
|
1179
|
+
dir = parent;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// A shallow clone cuts history at boundary commits, and those commits list
|
|
1184
|
+
// different files once the history is fetched. The saved lists are only good
|
|
1185
|
+
// for the shallow state they were read under; null means do not cache.
|
|
1186
|
+
function shallowStamp(root) {
|
|
1187
|
+
const common = gitCommonDir(root);
|
|
1188
|
+
if (!common) return null;
|
|
1189
|
+
try {
|
|
1190
|
+
return crypto.createHash('sha1').update(fs.readFileSync(path.join(common, 'shallow'))).digest('hex');
|
|
1191
|
+
} catch (error) {
|
|
1192
|
+
return error.code === 'ENOENT' ? 'full' : null;
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// A commit's file list never changes, so it is saved by hash in the ignored
|
|
1197
|
+
// .atris/cache and the gauge (shown on every boot) only asks git about commits
|
|
1198
|
+
// it has not seen. Hashes this read used come first; older ones stay until the
|
|
1199
|
+
// cap, so a 7-day boot read and a 14-day report read do not evict each other.
|
|
1200
|
+
const REVISION_FILES_CACHE = 'revision-files.json';
|
|
1201
|
+
const REVISION_FILES_CACHE_MAX = 5000;
|
|
1202
|
+
|
|
1203
|
+
function loadRevisionFilesCache(root) {
|
|
1204
|
+
const shallow = ignoredCache.cacheEnabled(root) ? shallowStamp(root) : null;
|
|
1205
|
+
const cache = { root, shallow, entries: {}, used: {}, dirty: false, writable: Boolean(shallow) };
|
|
1206
|
+
if (!cache.writable) return cache;
|
|
1207
|
+
const parsed = ignoredCache.readCacheJson(root, REVISION_FILES_CACHE);
|
|
1208
|
+
if (parsed && parsed.version === 1 && parsed.shallow === shallow && parsed.entries && typeof parsed.entries === 'object') {
|
|
1209
|
+
cache.entries = parsed.entries;
|
|
1210
|
+
}
|
|
1211
|
+
return cache;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
function saveRevisionFilesCache(cache) {
|
|
1215
|
+
if (!cache.writable || !cache.dirty) return;
|
|
1216
|
+
const entries = { ...cache.used };
|
|
1217
|
+
let count = Object.keys(entries).length;
|
|
1218
|
+
for (const [hash, files] of Object.entries(cache.entries)) {
|
|
1219
|
+
if (count >= REVISION_FILES_CACHE_MAX) break;
|
|
1220
|
+
if (entries[hash] || !Array.isArray(files)) continue;
|
|
1221
|
+
entries[hash] = files;
|
|
1222
|
+
count += 1;
|
|
1223
|
+
}
|
|
1224
|
+
ignoredCache.writeCacheJson(cache.root, REVISION_FILES_CACHE, { version: 1, shallow: cache.shallow, entries });
|
|
1109
1225
|
}
|
|
1110
1226
|
|
|
1111
1227
|
/**
|
|
@@ -1150,10 +1266,27 @@ function collectRevisionSignals(root, options = {}) {
|
|
|
1150
1266
|
// commits count as revision signals.
|
|
1151
1267
|
const humans = commits.filter((c) => !c.isAgent && c.parents.length < 2);
|
|
1152
1268
|
|
|
1153
|
-
|
|
1269
|
+
// Every landing's files are needed, and a human commit's only when it falls
|
|
1270
|
+
// inside some landing's window. Plain commits missing from the cache are
|
|
1271
|
+
// read in one batch; merges keep their own first-parent diff.
|
|
1272
|
+
const cache = loadRevisionFilesCache(root);
|
|
1273
|
+
const needed = [...landings, ...humans.filter((human) => landings.some((landing) => human.ms > landing.ms && human.ms <= landing.ms + REVISION_WINDOW_MS))];
|
|
1274
|
+
const missing = [];
|
|
1275
|
+
for (const commit of needed) {
|
|
1276
|
+
const hit = cache.entries[commit.hash];
|
|
1277
|
+
if (Array.isArray(hit)) cache.used[commit.hash] = hit;
|
|
1278
|
+
else if (commit.parents.length < 2) missing.push(commit);
|
|
1279
|
+
}
|
|
1280
|
+
for (const [hash, files] of plainCommitFiles(root, missing)) {
|
|
1281
|
+
cache.used[hash] = files;
|
|
1282
|
+
cache.dirty = true;
|
|
1283
|
+
}
|
|
1154
1284
|
const filesOf = (commit) => {
|
|
1155
|
-
if (!
|
|
1156
|
-
|
|
1285
|
+
if (!cache.used[commit.hash]) {
|
|
1286
|
+
cache.used[commit.hash] = commitFiles(root, commit);
|
|
1287
|
+
cache.dirty = true;
|
|
1288
|
+
}
|
|
1289
|
+
return cache.used[commit.hash];
|
|
1157
1290
|
};
|
|
1158
1291
|
|
|
1159
1292
|
const revisions = [];
|
|
@@ -1177,6 +1310,7 @@ function collectRevisionSignals(root, options = {}) {
|
|
|
1177
1310
|
});
|
|
1178
1311
|
}
|
|
1179
1312
|
}
|
|
1313
|
+
saveRevisionFilesCache(cache);
|
|
1180
1314
|
|
|
1181
1315
|
return {
|
|
1182
1316
|
schema: REVISIONS_SCHEMA,
|
package/commands/integrations.js
CHANGED
|
@@ -33,11 +33,12 @@ const {
|
|
|
33
33
|
const fs = require('fs');
|
|
34
34
|
const os = require('os');
|
|
35
35
|
const path = require('path');
|
|
36
|
+
const { stateHome } = require('../lib/state-home');
|
|
36
37
|
const readline = require('readline');
|
|
37
38
|
const { spawnSync } = require('child_process');
|
|
38
39
|
const { sleepSync } = require('../lib/sleep-sync');
|
|
39
40
|
|
|
40
|
-
const CALENDAR_CACHE_PATH = path.join(
|
|
41
|
+
const CALENDAR_CACHE_PATH = path.join(stateHome(), '.atris', 'calendar-events-cache.json');
|
|
41
42
|
const GMAIL_CONNECT_POLL_MS = 3000;
|
|
42
43
|
const GMAIL_CONNECT_TIMEOUT_MS = 3 * 60 * 1000;
|
|
43
44
|
const GMAIL_SEND_USAGE = 'usage: atris gmail send <to> <subject> <body...> [--body-file <path>] [--account <id>]';
|
|
@@ -182,7 +183,7 @@ function parseGmailVerdictsArgs(args = []) {
|
|
|
182
183
|
|
|
183
184
|
function gmailAccountStatePath() {
|
|
184
185
|
return process.env.ATRIS_GMAIL_ACCOUNT_FILE
|
|
185
|
-
|| path.join(
|
|
186
|
+
|| path.join(stateHome(), '.atris', 'gmail-account.json');
|
|
186
187
|
}
|
|
187
188
|
|
|
188
189
|
function readGmailStickyAccount() {
|
|
@@ -1794,7 +1795,7 @@ function normalizeContactLabel(value) {
|
|
|
1794
1795
|
}
|
|
1795
1796
|
|
|
1796
1797
|
function imessageLookupCachePath() {
|
|
1797
|
-
return path.join(
|
|
1798
|
+
return path.join(stateHome(), '.atris', 'cache', 'imessage-contacts.json');
|
|
1798
1799
|
}
|
|
1799
1800
|
|
|
1800
1801
|
function readImessageLookupCache() {
|
package/commands/loops.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
const fs = require('fs');
|
|
14
14
|
const os = require('os');
|
|
15
15
|
const path = require('path');
|
|
16
|
+
const { stateHome } = require('../lib/state-home');
|
|
16
17
|
const { execSync, spawnSync } = require('child_process');
|
|
17
18
|
|
|
18
19
|
const MAX_LOG_AGE_DAYS = 14;
|
|
@@ -26,7 +27,7 @@ const SCAFFOLD_FILES = [
|
|
|
26
27
|
];
|
|
27
28
|
|
|
28
29
|
function heartbeatDir() {
|
|
29
|
-
return process.env.ATRIS_HEARTBEAT_DIR || path.join(
|
|
30
|
+
return process.env.ATRIS_HEARTBEAT_DIR || path.join(stateHome(), '.atris', 'heartbeat');
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
function readJson(filePath, fallback) {
|
package/commands/mail.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// atris mail inboxes | read <inbox> | send <inbox> --to --subject --body
|
|
4
|
+
// Your agents' own email inboxes (the @atrismail.com ones), not your Gmail.
|
|
5
|
+
// For Gmail use `atris gmail`. Backend: /api/business/{id}/mail/...
|
|
6
|
+
|
|
7
|
+
const readline = require('readline');
|
|
8
|
+
const { apiRequestJson } = require('../utils/api');
|
|
9
|
+
const { NOT_LOGGED_IN } = require('../lib/developer-api');
|
|
10
|
+
const {
|
|
11
|
+
parseArgs, businessOrSay, loginToken, errorText, clip,
|
|
12
|
+
} = require('../lib/business-target');
|
|
13
|
+
|
|
14
|
+
// The only flags each subcommand takes (plus --help, --json, --business).
|
|
15
|
+
const MAIL_FLAGS = {
|
|
16
|
+
inboxes: {},
|
|
17
|
+
read: { values: ['limit'] },
|
|
18
|
+
send: { switches: ['yes', 'dry_run'], values: ['to', 'subject', 'body'] },
|
|
19
|
+
};
|
|
20
|
+
const ALL_MAIL_FLAGS = { switches: ['yes', 'dry_run'], values: ['limit', 'to', 'subject', 'body'] };
|
|
21
|
+
|
|
22
|
+
function mailHelp(log = console.log) {
|
|
23
|
+
log(`usage: atris mail inboxes [--business <slug>] [--json]
|
|
24
|
+
atris mail read <inbox> [--limit 10] [--json]
|
|
25
|
+
atris mail send <inbox> --to <address> --subject "<subject>" --body "<text>" [--yes] [--dry-run]
|
|
26
|
+
|
|
27
|
+
your agents' own email inboxes (not your Gmail; for that use atris gmail).
|
|
28
|
+
<inbox> is the address, the part before the @, or the inbox id.
|
|
29
|
+
|
|
30
|
+
inboxes list every agent inbox in this business
|
|
31
|
+
read newest messages first (--limit 1 to 200, default 10)
|
|
32
|
+
send send one email from an agent inbox. asks before sending unless --yes.
|
|
33
|
+
--dry-run prints the email and sends nothing.
|
|
34
|
+
unknown flags are refused, so a typo never sends anything.`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function toList(value) {
|
|
38
|
+
if (Array.isArray(value)) return value.map(String);
|
|
39
|
+
if (typeof value === 'string' && value.trim().startsWith('[')) {
|
|
40
|
+
try { return JSON.parse(value).map(String); } catch { /* fall through */ }
|
|
41
|
+
}
|
|
42
|
+
return value ? [String(value)] : [];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function renderInboxes(rows = []) {
|
|
46
|
+
if (!rows.length) return 'No agent inboxes yet. Hiring an agent makes one: atris hire "<name>" --job "<idea>"';
|
|
47
|
+
return rows.map((r) => `${r.email_address}${r.display_name ? ` (${r.display_name})` : ''}`).join('\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function renderMessages(inbox, rows = []) {
|
|
51
|
+
if (!rows.length) return `No mail in ${inbox.email_address} yet.`;
|
|
52
|
+
const lines = [];
|
|
53
|
+
for (const m of rows) {
|
|
54
|
+
const when = String(m.created_at || '').slice(0, 16).replace('T', ' ');
|
|
55
|
+
const out = m.direction === 'outbound';
|
|
56
|
+
const who = out ? `to ${toList(m.to_addresses).join(', ')}` : `from ${m.from_address || '?'}`;
|
|
57
|
+
lines.push(`${when} ${out ? 'sent' : 'got '} ${who}`);
|
|
58
|
+
lines.push(` ${clip(m.subject || '(no subject)', 100)}`);
|
|
59
|
+
const body = clip(m.body_text || '', 160);
|
|
60
|
+
if (body) lines.push(` ${body}`);
|
|
61
|
+
}
|
|
62
|
+
return lines.join('\n');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// address, local part, or id -> the inbox row
|
|
66
|
+
function findInbox(rows, wanted) {
|
|
67
|
+
const w = String(wanted || '').trim().toLowerCase();
|
|
68
|
+
if (!w) return null;
|
|
69
|
+
return rows.find((r) => String(r.id).toLowerCase() === w)
|
|
70
|
+
|| rows.find((r) => String(r.email_address || '').toLowerCase() === w)
|
|
71
|
+
|| rows.find((r) => String(r.email_address || '').toLowerCase().split('@')[0] === w)
|
|
72
|
+
|| null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function askYesNo(question) {
|
|
76
|
+
if (!process.stdin.isTTY) return Promise.resolve(false);
|
|
77
|
+
return new Promise((resolve) => {
|
|
78
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
79
|
+
rl.question(question, (answer) => {
|
|
80
|
+
rl.close();
|
|
81
|
+
resolve(/^y(es)?$/i.test(String(answer || '').trim()));
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function mailCommand(args = [], deps = {}) {
|
|
87
|
+
const out = { log: deps.log || console.log, err: deps.err || console.error };
|
|
88
|
+
const { flags, pos } = parseArgs(args, ALL_MAIL_FLAGS);
|
|
89
|
+
const sub = pos[0];
|
|
90
|
+
if (flags.help || !sub || sub === 'help') { mailHelp(out.log); return sub || flags.help ? 0 : 1; }
|
|
91
|
+
if (!MAIL_FLAGS[sub]) {
|
|
92
|
+
out.err(`unknown mail command: ${sub}. try: atris mail inboxes | read | send`);
|
|
93
|
+
return 1;
|
|
94
|
+
}
|
|
95
|
+
// Checked against this subcommand's own flags, before any request: an
|
|
96
|
+
// unknown or mistyped flag on send must never turn into a real email.
|
|
97
|
+
const { error } = parseArgs(args, MAIL_FLAGS[sub]);
|
|
98
|
+
if (error) { out.err(`${error}. see: atris mail --help`); return 1; }
|
|
99
|
+
const words = sub === 'inboxes' ? 1 : 2;
|
|
100
|
+
if (pos.length > words) {
|
|
101
|
+
out.err(`atris mail ${sub} got an extra word: "${pos[words]}". put text in quotes, like --body "hi there"`);
|
|
102
|
+
return 1;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const business = businessOrSay(flags, deps, out.err);
|
|
106
|
+
if (!business) return 1;
|
|
107
|
+
const token = await loginToken(deps);
|
|
108
|
+
if (!token) { out.err(NOT_LOGGED_IN); return 1; }
|
|
109
|
+
const request = deps.apiRequestJson || apiRequestJson;
|
|
110
|
+
const base = `/business/${encodeURIComponent(business.id)}/mail`;
|
|
111
|
+
|
|
112
|
+
const listed = await request(`${base}/inboxes`, { method: 'GET', token });
|
|
113
|
+
if (!listed || !listed.ok) { out.err(`Could not load inboxes: ${errorText(listed)}`); return 1; }
|
|
114
|
+
const inboxes = Array.isArray(listed.data) ? listed.data : [];
|
|
115
|
+
|
|
116
|
+
if (sub === 'inboxes') {
|
|
117
|
+
out.log(flags.json ? JSON.stringify(inboxes) : renderInboxes(inboxes));
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!pos[1]) { out.err(`which inbox? atris mail ${sub} <inbox>`); return 1; }
|
|
122
|
+
const inbox = findInbox(inboxes, pos[1]);
|
|
123
|
+
if (!inbox) { out.err(`No inbox matches "${pos[1]}". See them all: atris mail inboxes`); return 1; }
|
|
124
|
+
|
|
125
|
+
if (sub === 'read') {
|
|
126
|
+
const limit = flags.limit == null ? 10 : Number(flags.limit);
|
|
127
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 200) { out.err('--limit must be a number from 1 to 200'); return 1; }
|
|
128
|
+
const res = await request(`${base}/inboxes/${encodeURIComponent(inbox.id)}/messages?limit=${limit}`, { method: 'GET', token });
|
|
129
|
+
if (!res || !res.ok) { out.err(`Could not read ${inbox.email_address}: ${errorText(res)}`); return 1; }
|
|
130
|
+
const rows = Array.isArray(res.data) ? res.data : [];
|
|
131
|
+
out.log(flags.json ? JSON.stringify(rows) : renderMessages(inbox, rows));
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// send
|
|
136
|
+
const to = typeof flags.to === 'string' ? flags.to.trim() : '';
|
|
137
|
+
const subject = typeof flags.subject === 'string' ? flags.subject : '';
|
|
138
|
+
const text = typeof flags.body === 'string' ? flags.body : '';
|
|
139
|
+
if (!to || !subject || !text) { out.err('send needs --to, --subject, and --body'); return 1; }
|
|
140
|
+
const preview = `From: ${inbox.email_address}\nTo: ${to}\nSubject: ${subject}\n\n${text}\n`;
|
|
141
|
+
if (flags.dry_run === true) {
|
|
142
|
+
out.log(`${preview}\nDry run: nothing was sent.`);
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
// Only a bare --yes (or -y) skips the question; the parser refuses --yes=<anything>.
|
|
146
|
+
if (flags.yes !== true) {
|
|
147
|
+
out.log(preview);
|
|
148
|
+
const ok = await (deps.confirm || askYesNo)('Send this email? (y/N) ');
|
|
149
|
+
if (!ok) {
|
|
150
|
+
out.log(process.stdin.isTTY || deps.confirm ? 'Not sent.' : 'Not sent. Add --yes to send without asking.');
|
|
151
|
+
return 1;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const res = await request(`${base}/inboxes/${encodeURIComponent(inbox.id)}/messages/send`, {
|
|
155
|
+
method: 'POST', token, body: { to, subject, text }, retries: 0, timeoutMs: 30000,
|
|
156
|
+
});
|
|
157
|
+
if (!res || !res.ok) { out.err(`Not sent: ${errorText(res)}`); return 1; }
|
|
158
|
+
if (flags.json) out.log(JSON.stringify(res.data));
|
|
159
|
+
else out.log(`Sent from ${inbox.email_address} to ${to}.`);
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = { mailCommand, findInbox, renderInboxes, renderMessages };
|