atris 3.82.16 → 3.82.18
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 +1 -1
- package/commands/agent-client.js +51 -5
- package/commands/campaign.js +3 -1
- package/commands/ninety-nine.js +247 -24
- package/commands/usage.js +40 -4
- package/lib/campaign-engine-stall.js +95 -0
- package/lib/campaign-loop.js +16 -3
- package/lib/campaign-new.js +15 -4
- package/lib/design-api.js +15 -3
- package/lib/ninety-nine-sync.js +72 -2
- package/lib/ninety-nine.js +148 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -269,7 +269,7 @@ The owner makes the client once. The secret is shown one time, or written to a
|
|
|
269
269
|
private file with `--secret-file`:
|
|
270
270
|
|
|
271
271
|
```bash
|
|
272
|
-
atris agent-client create --name nightly-bot --scopes mcp:read,x-search --secret-file ./bot.secret
|
|
272
|
+
atris agent-client create --name nightly-bot --scopes mcp:workspace:read,x-search --secret-file ./bot.secret
|
|
273
273
|
atris agent-client list
|
|
274
274
|
atris agent-client rotate <client_id>
|
|
275
275
|
atris agent-client revoke <client_id>
|
package/commands/agent-client.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
const fs = require('fs');
|
|
11
11
|
const path = require('path');
|
|
12
12
|
const { apiRequestJson } = require('../utils/api');
|
|
13
|
-
const { ensureValidCredentials, readActiveCredentials } = require('../utils/auth');
|
|
13
|
+
const { ensureValidCredentials, readActiveCredentials, listProfiles, loadProfile, getSessionProfile } = require('../utils/auth');
|
|
14
14
|
const agentClient = require('../utils/agent-client');
|
|
15
15
|
const { readFlag } = require('../lib/arg-parser');
|
|
16
16
|
const { wantsJson, wantsHelp, oneLine } = require('../lib/developer-api');
|
|
@@ -19,7 +19,52 @@ const BASE_PATH = '/auth/agent-clients';
|
|
|
19
19
|
const ACTIONS = new Set(['create', 'list', 'revoke', 'rotate']);
|
|
20
20
|
const VALUE_FLAGS = ['--name', '--scopes', '--daily-credit-cap', '--ttl', '--secret-file'];
|
|
21
21
|
const SHOWN_ONCE = 'store this secret now, it will not be shown again:';
|
|
22
|
-
|
|
22
|
+
// Scopes an agent client can carry today. The server holds the real list and
|
|
23
|
+
// rejects anything else; this is what help and the unknown-scope hint show.
|
|
24
|
+
const COMMON_SCOPES = [
|
|
25
|
+
'x-search', 'youtube', 'gmail-read', 'calendar-read',
|
|
26
|
+
'files:read', 'files:write', 'scoreboard:read', 'ai:complete',
|
|
27
|
+
'mcp:workspace:read', 'mcp:publish',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
// The person's own saved logins, so the error can name a real switch command.
|
|
31
|
+
function humanProfileNames(deps = {}) {
|
|
32
|
+
const list = deps.listProfiles || listProfiles;
|
|
33
|
+
const load = deps.loadProfile || loadProfile;
|
|
34
|
+
try {
|
|
35
|
+
return list().filter((name) => {
|
|
36
|
+
const profile = load(name);
|
|
37
|
+
return profile && profile.token && !agentClient.isAgentClientCredential(profile);
|
|
38
|
+
});
|
|
39
|
+
} catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The switch command that actually reaches the login in use. ATRIS_PROFILE
|
|
45
|
+
// beats everything, then this terminal's own pick, then the global login, so
|
|
46
|
+
// a --global switch would not help a terminal that picked its own account.
|
|
47
|
+
function switchCommandFor(name, deps = {}) {
|
|
48
|
+
const env = deps.env || process.env;
|
|
49
|
+
if (String(env.ATRIS_PROFILE || '').trim()) return `export ATRIS_PROFILE=${name}`;
|
|
50
|
+
let session = null;
|
|
51
|
+
try { session = (deps.getSessionProfile || getSessionProfile)(); } catch { session = null; }
|
|
52
|
+
return session ? `atris switch ${name}` : `atris switch ${name} --global`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function cannotManageMessage(deps = {}) {
|
|
56
|
+
const names = humanProfileNames(deps);
|
|
57
|
+
const lead = 'agent clients are managed with your own login, not an agent client.';
|
|
58
|
+
if (names.length === 1) return `${lead} switch back: ${switchCommandFor(names[0], deps)}`;
|
|
59
|
+
if (names.length > 1) return `${lead} switch back with one of: ${names.map((n) => switchCommandFor(n, deps)).join(' | ')}`;
|
|
60
|
+
return `${lead} sign in as yourself: atris login --force`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function withScopeHint(message) {
|
|
64
|
+
const text = String(message || '');
|
|
65
|
+
if (!/unknown scope/i.test(text)) return text;
|
|
66
|
+
return `${text}. scopes you can use: ${COMMON_SCOPES.join(', ')}`;
|
|
67
|
+
}
|
|
23
68
|
|
|
24
69
|
function showAgentClientHelp(log = console.log) {
|
|
25
70
|
log('usage: atris agent-client create --name <name> --scopes <a,b> [--daily-credit-cap N] [--ttl SECONDS] [--secret-file <path>]');
|
|
@@ -30,6 +75,7 @@ function showAgentClientHelp(log = console.log) {
|
|
|
30
75
|
log('an agent client lets an agent sign itself in, with no person in the loop.');
|
|
31
76
|
log('create and rotate show the secret once. --secret-file writes it to a private file instead.');
|
|
32
77
|
log('the agent then runs: atris login --client-id <atc_...> --client-secret-file <path>');
|
|
78
|
+
log(`scopes you can use: ${COMMON_SCOPES.join(', ')}`);
|
|
33
79
|
log('needs your own login. add --json for machine output.');
|
|
34
80
|
}
|
|
35
81
|
|
|
@@ -170,7 +216,7 @@ async function agentClientCommand(args = [], deps = {}) {
|
|
|
170
216
|
|
|
171
217
|
const readActive = deps.readActiveCredentials || readActiveCredentials;
|
|
172
218
|
if (agentClient.isAgentClientCredential(readActive())) {
|
|
173
|
-
return fail(
|
|
219
|
+
return fail(cannotManageMessage(deps));
|
|
174
220
|
}
|
|
175
221
|
|
|
176
222
|
const request = deps.apiRequestJson || apiRequestJson;
|
|
@@ -180,7 +226,7 @@ async function agentClientCommand(args = [], deps = {}) {
|
|
|
180
226
|
return fail('not logged in. run: atris login');
|
|
181
227
|
}
|
|
182
228
|
if (ensured.source === agentClient.AUTH_TYPE || agentClient.isAgentClientCredential(ensured.credentials)) {
|
|
183
|
-
return fail(
|
|
229
|
+
return fail(cannotManageMessage(deps));
|
|
184
230
|
}
|
|
185
231
|
const token = ensured.credentials.token;
|
|
186
232
|
|
|
@@ -211,7 +257,7 @@ async function agentClientCommand(args = [], deps = {}) {
|
|
|
211
257
|
const secret = result && result.data && result.data.client_secret;
|
|
212
258
|
const message = status === 404 && parsed.action !== 'revoke' && parsed.action !== 'rotate'
|
|
213
259
|
? 'agent clients are not live on the server yet. try again after the backend update.'
|
|
214
|
-
: agentClient.scrub(oneLine(result && (result.data || result.error)), [token, secret]);
|
|
260
|
+
: withScopeHint(agentClient.scrub(oneLine(result && (result.data || result.error)), [token, secret]));
|
|
215
261
|
return fail(message, { status: status || 0 });
|
|
216
262
|
}
|
|
217
263
|
|
package/commands/campaign.js
CHANGED
|
@@ -821,7 +821,9 @@ async function driveLoop(args, ctx, verb, view) {
|
|
|
821
821
|
if (verb === 'propose') {
|
|
822
822
|
const proposals = await loop.propose(view.id);
|
|
823
823
|
const after = getCampaign(ctx.store, view.id);
|
|
824
|
-
|
|
824
|
+
const stall = proposals.length ? null : loop.lastStall();
|
|
825
|
+
const empty = stall ? `nothing usable was proposed: ${stall.line}` : 'nothing usable was proposed';
|
|
826
|
+
ctx.out(proposals.length ? `${proposals.length} proposed and claimed\n${renderShow(after)}` : empty, { ok: proposals.length > 0, proposals, campaign: after, next: decide(after), ...(stall ? { engine: stall.engine, cause: stall.kind, next_step: stall.next } : {}) });
|
|
825
827
|
return proposals.length ? 0 : 1;
|
|
826
828
|
}
|
|
827
829
|
if (verb === 'step') {
|
package/commands/ninety-nine.js
CHANGED
|
@@ -6,8 +6,20 @@ const { randomUUID } = require('node:crypto');
|
|
|
6
6
|
const { argsWantHelp } = require('../lib/noninteractive');
|
|
7
7
|
const { getAtrisDir, loadCredentials } = require('../utils/auth');
|
|
8
8
|
const { stateHome } = require('../lib/state-home');
|
|
9
|
-
const
|
|
9
|
+
const tty = require('node:tty');
|
|
10
|
+
const { apiRequestJson } = require('../utils/api');
|
|
11
|
+
const { validateSync, checkpointKey, syncLines, syncBlocks, blockAccountKey } = require('../lib/ninety-nine-sync');
|
|
10
12
|
const {
|
|
13
|
+
OUTCOMES,
|
|
14
|
+
INTENTION_MAX,
|
|
15
|
+
BLOCK_NOTE_MAX,
|
|
16
|
+
oneLine,
|
|
17
|
+
localDate,
|
|
18
|
+
withOutcome,
|
|
19
|
+
answerRecord,
|
|
20
|
+
unansweredBlocks,
|
|
21
|
+
blocksToday,
|
|
22
|
+
blocksLine,
|
|
11
23
|
validLength,
|
|
12
24
|
parseSessionLog,
|
|
13
25
|
foldSessions,
|
|
@@ -24,16 +36,16 @@ const {
|
|
|
24
36
|
|
|
25
37
|
function usage() {
|
|
26
38
|
return [
|
|
27
|
-
'usage: atris 99 focus <25|50|90>',
|
|
39
|
+
'usage: atris 99 focus <25|50|90> ["what you will get done"]',
|
|
28
40
|
' atris 99 meditate <10|20|30>',
|
|
29
41
|
' atris 99 show the timer and today',
|
|
30
|
-
' atris 99 <25|50|90>
|
|
42
|
+
' atris 99 <25|50|90> [intention] start focus',
|
|
31
43
|
' atris 99 <10|20|30> start meditation',
|
|
32
44
|
' atris 99 f <25|50|90>',
|
|
33
45
|
' atris 99 m <10|20|30>',
|
|
34
46
|
' atris 99 start <focus|meditate> <minutes>',
|
|
35
47
|
' atris 99 again repeat the last finished session',
|
|
36
|
-
' atris 99 stop [note words]',
|
|
48
|
+
' atris 99 stop [--outcome yes|partly|no] [--note text] [note words]',
|
|
37
49
|
' atris 99 pause',
|
|
38
50
|
' atris 99 resume',
|
|
39
51
|
' atris 99 status [--json]',
|
|
@@ -41,6 +53,10 @@ function usage() {
|
|
|
41
53
|
' atris 99 sync [--dry-run]',
|
|
42
54
|
' atris 99 help | --help',
|
|
43
55
|
'',
|
|
56
|
+
'a focus block that runs its full time asks "Did you get it done? (yes/partly/no)" in a terminal;',
|
|
57
|
+
'--outcome and --note answer it without asking. Scripts are never asked.',
|
|
58
|
+
'with sync on, each finished focus block also goes to your Project 99 day, so every face shows one count.',
|
|
59
|
+
'',
|
|
44
60
|
'sync is off without a sync block in ~/.atris/99/config.json (ATRIS_99_CONFIG overrides it).',
|
|
45
61
|
'personal: {"sync":{"target":"personal","app_slug":"project-99"}}',
|
|
46
62
|
'workspace: {"sync":{"target":"workspace","business":"your-business","workspace":"optional-id"}}',
|
|
@@ -67,7 +83,7 @@ function saveSyncState(file, state) {
|
|
|
67
83
|
}
|
|
68
84
|
}
|
|
69
85
|
|
|
70
|
-
async function syncNinetyNine(args, { log, logPath, configPath, statePath, request, credentials = loadCredentials }) {
|
|
86
|
+
async function syncNinetyNine(args, { log, logPath, configPath, statePath, request, credentials = loadCredentials, now = () => new Date() }) {
|
|
71
87
|
if (args.length > 1 || (args.length === 1 && args[0] !== '--dry-run')) {
|
|
72
88
|
log('usage: atris 99 sync [--dry-run]');
|
|
73
89
|
return 2;
|
|
@@ -107,6 +123,9 @@ async function syncNinetyNine(args, { log, logPath, configPath, statePath, reque
|
|
|
107
123
|
}
|
|
108
124
|
const key = checkpointKey(config.sync, creds);
|
|
109
125
|
const checkpoint = state.targets[key] || {};
|
|
126
|
+
if (state.blocks !== undefined && (!state.blocks || typeof state.blocks !== 'object' || Array.isArray(state.blocks))) {
|
|
127
|
+
throw new Error('check the 99 sync-state json');
|
|
128
|
+
}
|
|
110
129
|
if ((checkpoint.known !== undefined && (!Array.isArray(checkpoint.known) || !checkpoint.known.every((item) => typeof item === 'string')))
|
|
111
130
|
|| (checkpoint.since != null && !Number.isFinite(Date.parse(checkpoint.since)))) {
|
|
112
131
|
throw new Error('check the 99 sync-state json');
|
|
@@ -128,7 +147,7 @@ async function syncNinetyNine(args, { log, logPath, configPath, statePath, reque
|
|
|
128
147
|
saveSyncState(stateFile, state);
|
|
129
148
|
log(`sent ${result.send.length} lines and pulled ${result.pull.length} lines (${config.sync.target})`);
|
|
130
149
|
}
|
|
131
|
-
return
|
|
150
|
+
return await sendBlocks({ records: [...records, ...result.pull], state, stateFile, creds, dryRun, request, now, log });
|
|
132
151
|
} catch (error) {
|
|
133
152
|
const message = error.code === 'EEXIST' ? 'session sync is busy; try again'
|
|
134
153
|
: error.code ? 'check the sync file paths and permissions' : error.message;
|
|
@@ -144,6 +163,38 @@ async function syncNinetyNine(args, { log, logPath, configPath, statePath, reque
|
|
|
144
163
|
}
|
|
145
164
|
}
|
|
146
165
|
|
|
166
|
+
async function sendBlocks({ records, state, stateFile, creds, dryRun, request, now, log }) {
|
|
167
|
+
const key = blockAccountKey(creds);
|
|
168
|
+
const sent = state.blocks?.[key];
|
|
169
|
+
let result;
|
|
170
|
+
try {
|
|
171
|
+
result = await syncBlocks({
|
|
172
|
+
records, credentials: creds, dryRun, request, now: now(),
|
|
173
|
+
sent: sent && typeof sent === 'object' && !Array.isArray(sent) ? sent : {},
|
|
174
|
+
});
|
|
175
|
+
} catch (error) {
|
|
176
|
+
log(`could not send focus blocks: ${String(error.message).replace(/\s+/g, ' ')}`);
|
|
177
|
+
return 1;
|
|
178
|
+
}
|
|
179
|
+
const count = result.send.length;
|
|
180
|
+
const blocks = `${count} focus block${count === 1 ? '' : 's'}`;
|
|
181
|
+
if (dryRun) {
|
|
182
|
+
if (count) log(`would send ${blocks}`);
|
|
183
|
+
for (const block of result.send) log(`block: ${JSON.stringify(block)}`);
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
|
186
|
+
if (result.locked) {
|
|
187
|
+
log('focus blocks stay on this computer: deep work needs Project 99 Plus');
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
190
|
+
state.blocks = { ...(state.blocks || {}), [key]: result.sent };
|
|
191
|
+
saveSyncState(stateFile, state);
|
|
192
|
+
if (result.refused) log(`the 99 server refused some focus blocks (${result.refused}); they will not be sent again`);
|
|
193
|
+
else if (count) log(`sent ${blocks}`);
|
|
194
|
+
if (result.line) log(`Deep work today: ${result.line}`);
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
|
|
147
198
|
function readLog(logPath) {
|
|
148
199
|
try { return fs.readFileSync(logPath, 'utf8'); } catch (error) {
|
|
149
200
|
if (error.code === 'ENOENT') return '';
|
|
@@ -163,6 +214,122 @@ function todayLine(result) {
|
|
|
163
214
|
return `Today: ${result.counts.focus} focus, ${result.counts.meditate} meditate, ${result.finished_minutes} min`;
|
|
164
215
|
}
|
|
165
216
|
|
|
217
|
+
function parseOutcome(text) {
|
|
218
|
+
const answer = String(text ?? '').trim().toLowerCase();
|
|
219
|
+
if (['y', 'yes'].includes(answer)) return 'yes';
|
|
220
|
+
if (['p', 'partly', 'partial'].includes(answer)) return 'partly';
|
|
221
|
+
if (['n', 'no'].includes(answer)) return 'no';
|
|
222
|
+
return answer ? undefined : null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// One line from the terminal, read without a stream so the command stays synchronous.
|
|
226
|
+
function askTerminal(question) {
|
|
227
|
+
process.stdout.write(question);
|
|
228
|
+
const bytes = [];
|
|
229
|
+
const one = Buffer.alloc(1);
|
|
230
|
+
for (;;) {
|
|
231
|
+
let read;
|
|
232
|
+
try { read = fs.readSync(0, one, 0, 1, null); } catch (error) {
|
|
233
|
+
if (error.code === 'EAGAIN') {
|
|
234
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50); // stdin not ready yet
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
return '';
|
|
238
|
+
}
|
|
239
|
+
if (read === 0 || one[0] === 10) break;
|
|
240
|
+
bytes.push(one[0]);
|
|
241
|
+
}
|
|
242
|
+
return Buffer.from(bytes).toString('utf8');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// "Did you get it done?" until a clear answer, at most twice. Empty skips it: null.
|
|
246
|
+
function askOutcome(ask, lead) {
|
|
247
|
+
for (let tries = 0; tries < 2; tries++) {
|
|
248
|
+
const answer = parseOutcome(ask(`${lead}Did you get it done? (yes/partly/no) `));
|
|
249
|
+
if (answer !== undefined) return answer;
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// The sessions as the command finds them: who is running, what stop would end, and
|
|
255
|
+
// the ends to write for timers that finished or pauses that ran out.
|
|
256
|
+
function observe(records, time, action) {
|
|
257
|
+
const sessions = foldSessions(records);
|
|
258
|
+
const active = runningSession(sessions, time);
|
|
259
|
+
const stopTarget = action === 'stop'
|
|
260
|
+
? active || sessions.findLast((session) => automaticEndRecord(session, time)?.status === 'done')
|
|
261
|
+
: null;
|
|
262
|
+
const ends = sessions.filter((session) => session !== stopTarget)
|
|
263
|
+
.map((session) => automaticEndRecord(session, time)).filter(Boolean);
|
|
264
|
+
return { sessions, active, stopTarget, ends };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function lead(session) {
|
|
268
|
+
return `Focus ${session.planned_min} ended${session.intention ? ` (${session.intention})` : ''}. `;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// The questions this command would ask, asked now: block id -> yes, partly, no or null.
|
|
272
|
+
// A log that cannot be read asks nothing; the locked read reports it.
|
|
273
|
+
function askFirst(text, time, action, stopping, ask) {
|
|
274
|
+
const answers = new Map();
|
|
275
|
+
let records;
|
|
276
|
+
let seen;
|
|
277
|
+
try {
|
|
278
|
+
records = parseSessionLog(text);
|
|
279
|
+
seen = observe(records, time, action);
|
|
280
|
+
} catch {
|
|
281
|
+
return answers;
|
|
282
|
+
}
|
|
283
|
+
records.push(...seen.ends);
|
|
284
|
+
const unanswered = unansweredBlocks(records, time);
|
|
285
|
+
if (action === 'stop') {
|
|
286
|
+
if (stopping.outcome !== undefined) return answers;
|
|
287
|
+
const target = seen.stopTarget;
|
|
288
|
+
if (target) {
|
|
289
|
+
if (target.kind === 'focus' && endRecord(target, time).status === 'done') answers.set(target.id, askOutcome(ask, ''));
|
|
290
|
+
} else if (unanswered.length) {
|
|
291
|
+
const last = unanswered.at(-1);
|
|
292
|
+
answers.set(last.id, askOutcome(ask, lead(last)));
|
|
293
|
+
}
|
|
294
|
+
return answers;
|
|
295
|
+
}
|
|
296
|
+
for (const session of unanswered) answers.set(session.id, askOutcome(ask, lead(session)));
|
|
297
|
+
return answers;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function fetchSuggestion(time, { request = apiRequestJson, credentials = loadCredentials } = {}) {
|
|
301
|
+
try {
|
|
302
|
+
const creds = credentials();
|
|
303
|
+
if (!creds?.token) return '';
|
|
304
|
+
const query = new URLSearchParams({ day: localDate(time), tz_offset_minutes: String(-time.getTimezoneOffset()) });
|
|
305
|
+
const response = await request(`/99/focus/suggestion?${query}`, {
|
|
306
|
+
method: 'GET', token: creds.token, timeoutMs: 6000, retries: 0,
|
|
307
|
+
});
|
|
308
|
+
const line = response?.ok && typeof response.data?.suggestion === 'string' ? response.data.suggestion : '';
|
|
309
|
+
return oneLine(line, 120);
|
|
310
|
+
} catch {
|
|
311
|
+
return '';
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function stopArgs(words) {
|
|
316
|
+
const out = { note: [], outcome: undefined, flagNote: undefined };
|
|
317
|
+
for (let index = 0; index < words.length; index++) {
|
|
318
|
+
const word = words[index];
|
|
319
|
+
const [flag, inline] = word.startsWith('--') ? [word.split('=')[0], word.includes('=') ? word.slice(word.indexOf('=') + 1) : undefined] : [];
|
|
320
|
+
if (flag === '--outcome' || flag === '--note') {
|
|
321
|
+
const value = inline !== undefined ? inline : words[++index];
|
|
322
|
+
if (value === undefined) return { error: true };
|
|
323
|
+
if (flag === '--outcome') {
|
|
324
|
+
if (!OUTCOMES.includes(value)) return { error: true };
|
|
325
|
+
out.outcome = value;
|
|
326
|
+
} else out.flagNote = value;
|
|
327
|
+
} else out.note.push(word);
|
|
328
|
+
}
|
|
329
|
+
const note = out.flagNote !== undefined ? [out.flagNote, ...out.note].join(' ') : out.note.join(' ');
|
|
330
|
+
return { outcome: out.outcome, note: oneLine(note, BLOCK_NOTE_MAX) };
|
|
331
|
+
}
|
|
332
|
+
|
|
166
333
|
function kindName(kind) {
|
|
167
334
|
return kind === 'focus' ? 'Focus' : 'Meditate';
|
|
168
335
|
}
|
|
@@ -172,6 +339,11 @@ function timeLeft(session, time) {
|
|
|
172
339
|
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;
|
|
173
340
|
}
|
|
174
341
|
|
|
342
|
+
function runningLine(active, time) {
|
|
343
|
+
return `${kindName(active.kind)} · ${timeLeft(active, time)} left${active.status === 'paused' ? ' · paused' : ''}`
|
|
344
|
+
+ (active.intention ? ` · ${active.intention}` : '');
|
|
345
|
+
}
|
|
346
|
+
|
|
175
347
|
function localEndTime(end) {
|
|
176
348
|
return end.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
|
177
349
|
}
|
|
@@ -184,6 +356,9 @@ function ninetyNineAtris(args = process.argv.slice(3), {
|
|
|
184
356
|
statePath,
|
|
185
357
|
request,
|
|
186
358
|
credentials,
|
|
359
|
+
interactive = tty.isatty(0),
|
|
360
|
+
ask = askTerminal,
|
|
361
|
+
suggestion = (time) => fetchSuggestion(time, { request, credentials }),
|
|
187
362
|
} = {}) {
|
|
188
363
|
let [action, ...rest] = args;
|
|
189
364
|
const overview = args.length === 0;
|
|
@@ -192,7 +367,7 @@ function ninetyNineAtris(args = process.argv.slice(3), {
|
|
|
192
367
|
log(usage());
|
|
193
368
|
return 0;
|
|
194
369
|
}
|
|
195
|
-
if (action === 'sync') return syncNinetyNine(rest, { log, logPath, configPath, statePath, request, credentials });
|
|
370
|
+
if (action === 'sync') return syncNinetyNine(rest, { log, logPath, configPath, statePath, request, credentials, now });
|
|
196
371
|
if (action === 'start' && ['focus', 'meditate', 'f', 'm'].includes(rest[0])) [action, ...rest] = rest;
|
|
197
372
|
if (action === 'f') action = 'focus';
|
|
198
373
|
if (action === 'm') action = 'meditate';
|
|
@@ -204,16 +379,29 @@ function ninetyNineAtris(args = process.argv.slice(3), {
|
|
|
204
379
|
}
|
|
205
380
|
}
|
|
206
381
|
const json = rest.includes('--json');
|
|
382
|
+
let intention = '';
|
|
383
|
+
let stopping;
|
|
207
384
|
if (action === 'focus' || action === 'meditate') {
|
|
208
385
|
const lengths = action === 'focus' ? '25 | 50 | 90' : '10 | 20 | 30';
|
|
209
386
|
if (rest.length === 0) {
|
|
210
387
|
log(`Pick a length: atris 99 ${action} ${lengths}`);
|
|
211
388
|
return 1;
|
|
212
389
|
}
|
|
213
|
-
|
|
390
|
+
// Focus takes what the block is for after its length; meditation takes nothing more.
|
|
391
|
+
const words = rest.slice(1);
|
|
392
|
+
if ((action === 'focus' ? words.some((word) => word.startsWith('--')) : words.length)
|
|
393
|
+
|| !validLength(action, Number(rest[0])) || String(Number(rest[0])) !== rest[0]) {
|
|
214
394
|
log(`Allowed ${action} lengths: ${lengths.replaceAll(' | ', ', ')} minutes.`);
|
|
215
395
|
return 1;
|
|
216
396
|
}
|
|
397
|
+
intention = oneLine(words.join(' '), INTENTION_MAX);
|
|
398
|
+
rest = [rest[0]];
|
|
399
|
+
} else if (action === 'stop') {
|
|
400
|
+
stopping = stopArgs(rest);
|
|
401
|
+
if (stopping.error) {
|
|
402
|
+
log('usage: atris 99 stop [--outcome yes|partly|no] [--note text] [note words]');
|
|
403
|
+
return 2;
|
|
404
|
+
}
|
|
217
405
|
} else if (action === 'status' || action === 'today') {
|
|
218
406
|
if (rest.length > 1 || (rest.length === 1 && !json)) {
|
|
219
407
|
log(`usage: atris 99 ${action} [--json]`);
|
|
@@ -232,12 +420,16 @@ function ninetyNineAtris(args = process.argv.slice(3), {
|
|
|
232
420
|
let lock;
|
|
233
421
|
let lockPath;
|
|
234
422
|
try {
|
|
423
|
+
const time = now();
|
|
235
424
|
const override = logPath || process.env.ATRIS_99_LOG;
|
|
236
425
|
const file = override || path.join(getAtrisDir(), '99', 'sessions.jsonl');
|
|
237
426
|
const dir = path.dirname(file);
|
|
238
427
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
239
428
|
// An override may live in a shared folder. Only tighten our own folder.
|
|
240
429
|
if (!override) fs.chmodSync(dir, 0o700);
|
|
430
|
+
// Ask a person at a terminal before taking the lock, so an open question never
|
|
431
|
+
// makes another command or the Mac app wait. Scripts and json readers are never asked.
|
|
432
|
+
const answers = interactive && !json ? askFirst(readLog(file), time, action, stopping, ask) : new Map();
|
|
241
433
|
lockPath = `${file}.lock`;
|
|
242
434
|
try { lock = fs.openSync(lockPath, 'wx', 0o600); } catch (error) {
|
|
243
435
|
if (error.code === 'EEXIST') throw new Error('session log is busy; try again');
|
|
@@ -245,27 +437,41 @@ function ninetyNineAtris(args = process.argv.slice(3), {
|
|
|
245
437
|
}
|
|
246
438
|
let text = readLog(file);
|
|
247
439
|
const records = parseSessionLog(text);
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
let active =
|
|
251
|
-
const stopTarget =
|
|
252
|
-
|
|
253
|
-
|
|
440
|
+
const seen = observe(records, time, action);
|
|
441
|
+
let sessions = seen.sessions;
|
|
442
|
+
let active = seen.active;
|
|
443
|
+
const stopTarget = seen.stopTarget;
|
|
444
|
+
for (const end of seen.ends) {
|
|
445
|
+
text = appendRecord(file, text, end);
|
|
446
|
+
records.push(end);
|
|
447
|
+
}
|
|
254
448
|
|
|
255
|
-
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
449
|
+
const answer = (session, outcome) => {
|
|
450
|
+
const record = answerRecord(session, time, outcome);
|
|
451
|
+
text = appendRecord(file, text, record);
|
|
452
|
+
records.push(record);
|
|
453
|
+
};
|
|
454
|
+
if (action !== 'stop') {
|
|
455
|
+
for (const session of unansweredBlocks(records, time)) {
|
|
456
|
+
if (answers.has(session.id)) answer(session, answers.get(session.id));
|
|
260
457
|
}
|
|
261
458
|
}
|
|
262
459
|
|
|
263
460
|
if (action === 'stop') {
|
|
264
461
|
if (!stopTarget) {
|
|
462
|
+
// Nothing running: --outcome, or the question, answers the last block that ended on its own.
|
|
463
|
+
const last = unansweredBlocks(records, time).at(-1);
|
|
464
|
+
const outcome = last && (stopping.outcome ?? answers.get(last.id));
|
|
465
|
+
if (last && outcome !== undefined) {
|
|
466
|
+
answer(last, outcome);
|
|
467
|
+
log(`Focus ${last.planned_min}: ${outcome || 'skipped'}.`);
|
|
468
|
+
return 0;
|
|
469
|
+
}
|
|
265
470
|
log('Nothing running. Start one: atris 99 50');
|
|
266
471
|
return 1;
|
|
267
472
|
}
|
|
268
|
-
const end = endRecord(stopTarget, time,
|
|
473
|
+
const end = endRecord(stopTarget, time, stopping.note);
|
|
474
|
+
withOutcome(end, stopTarget, stopping.outcome ?? answers.get(stopTarget.id));
|
|
269
475
|
appendRecord(file, text, end);
|
|
270
476
|
records.push(end);
|
|
271
477
|
log(end.status === 'done'
|
|
@@ -289,7 +495,7 @@ function ninetyNineAtris(args = process.argv.slice(3), {
|
|
|
289
495
|
}
|
|
290
496
|
if (overview) {
|
|
291
497
|
if (!active) log('Nothing running.');
|
|
292
|
-
else log(
|
|
498
|
+
else log(runningLine(active, time));
|
|
293
499
|
log(todayLine(sessionsToday(sessions, time)));
|
|
294
500
|
log('Start: atris 99 50 Again: atris 99 again Help: atris 99 help');
|
|
295
501
|
} else if (action === 'focus' || action === 'meditate') {
|
|
@@ -297,9 +503,20 @@ function ninetyNineAtris(args = process.argv.slice(3), {
|
|
|
297
503
|
log('a session is already running; use atris 99 stop first');
|
|
298
504
|
return 1;
|
|
299
505
|
}
|
|
300
|
-
appendRecord(file, text, startRecord(action, Number(rest[0]), time, randomUUID()));
|
|
506
|
+
appendRecord(file, text, startRecord(action, Number(rest[0]), time, randomUUID(), intention));
|
|
301
507
|
const end = new Date(time.getTime() + Number(rest[0]) * 60000);
|
|
302
508
|
log(`${kindName(action)} ${rest[0]} started. Ends at ${localEndTime(end)}.`);
|
|
509
|
+
if (action === 'focus' && !intention && suggestion) {
|
|
510
|
+
// The timer has started; the hint never delays it and a failure prints nothing.
|
|
511
|
+
const hint = suggestion(time);
|
|
512
|
+
const show = (line) => {
|
|
513
|
+
const text = oneLine(line, 120);
|
|
514
|
+
if (text) log(`Suggestion: ${text}`);
|
|
515
|
+
return 0;
|
|
516
|
+
};
|
|
517
|
+
if (hint && typeof hint.then === 'function') return hint.then(show, () => 0);
|
|
518
|
+
show(hint);
|
|
519
|
+
}
|
|
303
520
|
} else if (action === 'pause' || action === 'resume') {
|
|
304
521
|
if (!active) {
|
|
305
522
|
log('Nothing running. Start one: atris 99 50');
|
|
@@ -324,13 +541,19 @@ function ninetyNineAtris(args = process.argv.slice(3), {
|
|
|
324
541
|
const result = sessionStatus(active, time);
|
|
325
542
|
if (json) log(JSON.stringify(result));
|
|
326
543
|
else if (!active) log('nothing running');
|
|
327
|
-
else log(
|
|
544
|
+
else log(runningLine(active, time));
|
|
328
545
|
} else {
|
|
329
546
|
const result = sessionsToday(sessions, time);
|
|
330
547
|
const health = healthToday(records, time);
|
|
331
548
|
if (health) result.health_day = health;
|
|
549
|
+
const blocks = blocksToday(sessions, time);
|
|
550
|
+
result.deep_work = { block_count: blocks.count, block_minutes: blocks.minutes };
|
|
332
551
|
if (json) log(JSON.stringify(result));
|
|
333
|
-
else
|
|
552
|
+
else {
|
|
553
|
+
log(todayLine(result));
|
|
554
|
+
const line = blocksLine(blocks);
|
|
555
|
+
if (line) log(`Deep work: ${line}`);
|
|
556
|
+
}
|
|
334
557
|
}
|
|
335
558
|
return 0;
|
|
336
559
|
} catch (error) {
|
package/commands/usage.js
CHANGED
|
@@ -15,12 +15,38 @@ const {
|
|
|
15
15
|
|
|
16
16
|
function showUsageHelp() {
|
|
17
17
|
console.log('usage: atris usage [--json]');
|
|
18
|
-
console.log('show developer api usage
|
|
18
|
+
console.log('show developer api usage: every key you own when signed in, or one key with ATRIS_API_KEY.');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function plural(n, word) {
|
|
22
|
+
return `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Signed in as a person, the server answers with every developer key you own
|
|
26
|
+
// plus a total. One line per key, then the total.
|
|
27
|
+
function renderKeyBreakdown(usage) {
|
|
28
|
+
const total = usage.total && typeof usage.total === 'object' ? usage.total : {};
|
|
29
|
+
const days = Number(total.period_days) || 30;
|
|
30
|
+
const lines = [`last ${days} days, ${plural(usage.keys.length, 'developer key')}:`];
|
|
31
|
+
for (const entry of usage.keys) {
|
|
32
|
+
const u = (entry && entry.usage) || {};
|
|
33
|
+
const label = `${entry.agent_name || 'key'} (${u.api_key || 'key'})`;
|
|
34
|
+
const errors = Number(u.error_count) || 0;
|
|
35
|
+
lines.push(` ${label}: ${plural(Number(u.total_requests) || 0, 'request')}${errors ? `, ${plural(errors, 'error')}` : ''}`);
|
|
36
|
+
}
|
|
37
|
+
const totalErrors = Number(total.error_count) || 0;
|
|
38
|
+
lines.push(`total: ${plural(Number(total.total_requests) || 0, 'request')}${totalErrors ? `, ${plural(totalErrors, 'error')} (${total.error_rate}%)` : ''}`);
|
|
39
|
+
const endpoints = Object.entries(total.endpoints || {}).sort((a, b) => b[1] - a[1]).slice(0, 5);
|
|
40
|
+
if (endpoints.length) lines.push(`busiest: ${endpoints.map(([name, count]) => `${name} ${count}`).join(', ')}`);
|
|
41
|
+
return lines.join('\n');
|
|
19
42
|
}
|
|
20
43
|
|
|
21
44
|
function renderUsage(data = {}) {
|
|
22
45
|
const raw = data && typeof data === 'object' ? data : {};
|
|
23
46
|
const usage = raw.usage && typeof raw.usage === 'object' ? raw.usage : raw;
|
|
47
|
+
if (Array.isArray(usage.keys)) {
|
|
48
|
+
return usage.keys.length ? renderKeyBreakdown(usage) : 'no developer keys yet. make one with atris api-key create';
|
|
49
|
+
}
|
|
24
50
|
const lines = [];
|
|
25
51
|
if (raw.agent_name || raw.agent_id) {
|
|
26
52
|
lines.push(`${raw.agent_name || 'agent'} ${raw.agent_id || ''}`.trim());
|
|
@@ -41,6 +67,8 @@ function renderUsage(data = {}) {
|
|
|
41
67
|
return lines.join('\n');
|
|
42
68
|
}
|
|
43
69
|
|
|
70
|
+
const USAGE_NEEDS_KEY = 'usage is counted per developer key, not per sign-in. make one with atris api-key create, then run ATRIS_API_KEY=<key> atris usage';
|
|
71
|
+
|
|
44
72
|
async function usageCommand(args = [], deps = {}) {
|
|
45
73
|
if (wantsHelp(args)) {
|
|
46
74
|
showUsageHelp();
|
|
@@ -51,14 +79,21 @@ async function usageCommand(args = [], deps = {}) {
|
|
|
51
79
|
const io = { log: deps.log || console.log, err: deps.err || console.error };
|
|
52
80
|
const load = deps.loadCredentials || loadCredentials;
|
|
53
81
|
const request = deps.apiRequestJson || apiRequestJson;
|
|
82
|
+
const devKeyEarly = String(((deps.env || process.env).ATRIS_API_KEY) || '').trim();
|
|
54
83
|
const token = tokenFrom(load());
|
|
55
|
-
if (!token) {
|
|
84
|
+
if (!token && !devKeyEarly) {
|
|
56
85
|
printResult({ json, ok: false, error: NOT_LOGGED_IN, payload: { ok: false, error: NOT_LOGGED_IN } }, io);
|
|
57
86
|
return 1;
|
|
58
87
|
}
|
|
59
88
|
|
|
60
89
|
try {
|
|
61
|
-
|
|
90
|
+
// The usage endpoint reads only X-API-Key: it reports one developer key's
|
|
91
|
+
// usage. Send a developer key when one is set; a sign-in token is refused.
|
|
92
|
+
const env = deps.env || process.env;
|
|
93
|
+
const devKey = String(env.ATRIS_API_KEY || '').trim();
|
|
94
|
+
const result = devKey
|
|
95
|
+
? await request('/developer/usage', { method: 'GET', headers: { 'X-API-Key': devKey } })
|
|
96
|
+
: await request('/developer/usage', { method: 'GET', token });
|
|
62
97
|
if (isNotFound(result)) {
|
|
63
98
|
printResult({
|
|
64
99
|
json,
|
|
@@ -69,7 +104,8 @@ async function usageCommand(args = [], deps = {}) {
|
|
|
69
104
|
return 1;
|
|
70
105
|
}
|
|
71
106
|
if (!result || !result.ok) {
|
|
72
|
-
|
|
107
|
+
let error = oneLine(result && result.error);
|
|
108
|
+
if (!devKey && [401, 403, 422].includes(result && result.status)) error = `${error}. ${USAGE_NEEDS_KEY}`;
|
|
73
109
|
printResult({ json, ok: false, error, payload: { ok: false, error, status: result && result.status } }, io);
|
|
74
110
|
return 1;
|
|
75
111
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// When the owner's engine gives a campaign nothing (no judge draft, no
|
|
2
|
+
// missions), say which engine was asked and why it likely failed, then one
|
|
3
|
+
// next step. Used by campaign new (lib/campaign-new.js) and propose and step
|
|
4
|
+
// (lib/campaign-loop.js). It never invents work; it only ends the stall in
|
|
5
|
+
// something a person can do.
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const NOT_INSTALLED = /not installed|command not found|\benoent\b/i;
|
|
10
|
+
const SIGNED_OUT = /not (?:logged|signed) in|please log ?in|log ?in required|login required|not authenticated|unauthori[sz]ed|auth(?:entication)? (?:expired|required)|\b401\b/i;
|
|
11
|
+
const USAGE_LIMIT = /usage[ _-]?limit|rate[ _-]?limit|insufficient credits|purchase more credits|quota|payment required/i;
|
|
12
|
+
|
|
13
|
+
// The ask runner's answer, cut to what the stall line needs. A clean answer
|
|
14
|
+
// stays { text, engine, ok }; the runner's reason, exit and stderr ride along
|
|
15
|
+
// only when it failed and said something.
|
|
16
|
+
function askSummary(answer, engine, extra = {}) {
|
|
17
|
+
const a = answer || {};
|
|
18
|
+
const summary = { text: a.stdout || '', engine, ok: Boolean(a.ok) };
|
|
19
|
+
if (!summary.ok) {
|
|
20
|
+
if (a.reason) summary.reason = a.reason;
|
|
21
|
+
if (Number.isInteger(a.exit_code)) summary.exit_code = a.exit_code;
|
|
22
|
+
if (a.stderr) summary.stderr = String(a.stderr).slice(-2000);
|
|
23
|
+
if (a.timed_out) summary.timed_out = true;
|
|
24
|
+
}
|
|
25
|
+
for (const [key, value] of Object.entries(extra)) if (value) summary[key] = value;
|
|
26
|
+
return summary;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// What went wrong, from what the runner saw. Fakes and older callers pass
|
|
30
|
+
// only { text, engine }; those read as "ran but sent back nothing".
|
|
31
|
+
function stallCause(answer = {}) {
|
|
32
|
+
const engine = answer.engine || 'unknown';
|
|
33
|
+
const said = `${answer.reason || ''}\n${answer.stderr || ''}`;
|
|
34
|
+
const text = String(answer.text || '').trim();
|
|
35
|
+
if (answer.reason === 'spawn_error' || (!answer.ok && NOT_INSTALLED.test(said))) {
|
|
36
|
+
// With no ready engine the ask falls back to one; say so when that one is missing too.
|
|
37
|
+
return { kind: answer.no_engine ? 'no_engine' : 'not_installed', engine };
|
|
38
|
+
}
|
|
39
|
+
if (!answer.ok && SIGNED_OUT.test(said)) return { kind: 'signed_out', engine };
|
|
40
|
+
if (!answer.ok && USAGE_LIMIT.test(said)) return { kind: 'usage_limit', engine };
|
|
41
|
+
if (answer.reason === 'timeout' || answer.timed_out) return { kind: 'timed_out', engine };
|
|
42
|
+
if (/^exit_/.test(answer.reason || '') && answer.exit_code !== 0) return { kind: 'exited', engine, exit: answer.exit_code };
|
|
43
|
+
if (!text) return { kind: 'empty', engine };
|
|
44
|
+
return { kind: 'unusable', engine };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The engine's own sign-in command, from the login table engine login uses.
|
|
48
|
+
function signInStep(engine) {
|
|
49
|
+
try {
|
|
50
|
+
const { ENGINE_LOGIN_MANIFESTS } = require('../commands/engine');
|
|
51
|
+
const hint = ENGINE_LOGIN_MANIFESTS[engine] && ENGINE_LOGIN_MANIFESTS[engine].missingHint;
|
|
52
|
+
if (hint) return hint.replace(/\s+first$/, '');
|
|
53
|
+
} catch {}
|
|
54
|
+
return `sign in to ${engine}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The engine is the problem when it never ran or could not answer; then the
|
|
58
|
+
// next step is to fix it. Otherwise it ran and the next step is by hand.
|
|
59
|
+
const ENGINE_KINDS = new Set(['no_engine', 'not_installed', 'signed_out', 'exited']);
|
|
60
|
+
|
|
61
|
+
// owner: the member asked. wanted: what it was asked for ("a judge script",
|
|
62
|
+
// "missions"). byHand: the exact command to do it without the engine.
|
|
63
|
+
// again: what to run once the engine works.
|
|
64
|
+
function explainStall({ owner, answer, wanted, byHand, again }) {
|
|
65
|
+
const cause = stallCause(answer);
|
|
66
|
+
const e = cause.engine;
|
|
67
|
+
const why = {
|
|
68
|
+
no_engine: `no engine on this computer is ready to do ${owner}'s work and the fallback ${e} is not installed`,
|
|
69
|
+
not_installed: `${owner}'s engine is ${e} and ${e} is not installed on this computer`,
|
|
70
|
+
signed_out: `${owner}'s engine is ${e} and ${e} is not signed in`,
|
|
71
|
+
usage_limit: `${owner}'s engine is ${e} and ${e} hit its usage limit`,
|
|
72
|
+
timed_out: `${owner}'s engine is ${e} and ${e} ran out of time before it answered`,
|
|
73
|
+
exited: `${owner}'s engine is ${e} and ${e} stopped with exit ${cause.exit} before it answered`,
|
|
74
|
+
empty: `${owner}'s engine is ${e} and ${e} ran but sent back nothing`,
|
|
75
|
+
unusable: `${owner}'s engine is ${e} and ${e} answered without ${wanted} atris could use`,
|
|
76
|
+
}[cause.kind];
|
|
77
|
+
const fix = {
|
|
78
|
+
no_engine: 'install codex or claude code',
|
|
79
|
+
not_installed: `install ${e}`,
|
|
80
|
+
signed_out: signInStep(e),
|
|
81
|
+
exited: `run atris engine test ${e} to see why it stopped`,
|
|
82
|
+
}[cause.kind];
|
|
83
|
+
const next = ENGINE_KINDS.has(cause.kind) && again
|
|
84
|
+
? `${fix}, then run ${again} again`
|
|
85
|
+
: byHand;
|
|
86
|
+
return { kind: cause.kind, engine: e, why, next, line: `${why}.\nnext: ${next}`, inline: `${why}. next: ${next}` };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// True when asking again cannot help: the engine never ran.
|
|
90
|
+
function engineCannotRun(answer) {
|
|
91
|
+
const kind = stallCause(answer).kind;
|
|
92
|
+
return kind === 'no_engine' || kind === 'not_installed' || kind === 'signed_out';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { askSummary, engineCannotRun, explainStall };
|
package/lib/campaign-loop.js
CHANGED
|
@@ -15,6 +15,7 @@ const { getCampaign, openCampaignStore } = require('./campaign-store');
|
|
|
15
15
|
const { defaultReserve, missionCost, reconcileCampaign } = require('./campaign-missions');
|
|
16
16
|
const { confirmedReading, judgeRanData, latestJudgeOutput } = require('./campaign-reading');
|
|
17
17
|
const { LANE_ASK_KIND, pausedLane, laneAskOpen, laneQuestion, openLaneAsks } = require('./campaign-lanes');
|
|
18
|
+
const { explainStall } = require('./campaign-engine-stall');
|
|
18
19
|
|
|
19
20
|
const TERMINAL = new Set(['stopped', 'complete']);
|
|
20
21
|
|
|
@@ -182,10 +183,11 @@ function parseProposals(text, view, count, rsiSummary = null, events = null) {
|
|
|
182
183
|
async function askOwner(root, view, prompt) {
|
|
183
184
|
const { resolveEngineForMember } = require('./member-engine');
|
|
184
185
|
const { runEngineAskJobs } = require('./engine-ask');
|
|
186
|
+
const { askSummary } = require('./campaign-engine-stall');
|
|
185
187
|
const pick = resolveEngineForMember(view.owner, root) || {};
|
|
186
188
|
const engine = (pick.engine && pick.engine.id) || 'claude';
|
|
187
189
|
const [answer] = await runEngineAskJobs([{ engine, model: pick.model || '', prompt, label: `campaign ${view.id} plan` }], { root, timeoutMs: PROPOSE_TIMEOUT_MS });
|
|
188
|
-
return
|
|
190
|
+
return askSummary(answer, engine, { no_engine: !(pick.engine && pick.engine.id) });
|
|
189
191
|
}
|
|
190
192
|
|
|
191
193
|
// Run one atris command and resolve with its exit code and output.
|
|
@@ -241,6 +243,9 @@ function makeLoop(root, options = {}, deps = {}) {
|
|
|
241
243
|
const view = (id) => getCampaign(store, id);
|
|
242
244
|
const append = (id, type, data, as = actor) => store.append({ campaign_id: id, type, actor: as, data });
|
|
243
245
|
|
|
246
|
+
// Why the last propose came back empty plus the one next step.
|
|
247
|
+
let lastStall = null;
|
|
248
|
+
|
|
244
249
|
async function propose(id, count = parallel) {
|
|
245
250
|
const events = store.read(id);
|
|
246
251
|
const current = events.length ? fold(events) : null;
|
|
@@ -259,6 +264,13 @@ function makeLoop(root, options = {}, deps = {}) {
|
|
|
259
264
|
const prompt = proposalPrompt(current, count, latestJudgeOutput(events), deps.lessons ? deps.lessons(id) : '', rsiSummary, events, brief);
|
|
260
265
|
const answer = await ask(root, current, prompt);
|
|
261
266
|
const proposals = parseProposals(answer.text, current, count, rsiSummary, events);
|
|
267
|
+
lastStall = proposals.length ? null : explainStall({
|
|
268
|
+
owner: current.owner,
|
|
269
|
+
answer,
|
|
270
|
+
wanted: 'missions',
|
|
271
|
+
byHand: `add one yourself: atris mission start "<what to do next>" --campaign ${id} --owner ${current.owner}`,
|
|
272
|
+
again: `atris campaign step ${id}`,
|
|
273
|
+
});
|
|
262
274
|
// Planning costs money too. It rides on the first mission it produced as a
|
|
263
275
|
// step, so it is counted when that mission ends without making the judge
|
|
264
276
|
// due. A plan that produced nothing is not failed work: the step saves a
|
|
@@ -421,7 +433,8 @@ function makeLoop(root, options = {}, deps = {}) {
|
|
|
421
433
|
const data = await readJudge(id, current, () => WAIT_FIRST_MS);
|
|
422
434
|
if (data.passed) return { stop: false, did: 'judge' };
|
|
423
435
|
const next = data.wait ? `; next check at ${clockTime(data.wait.wake_at)}` : '';
|
|
424
|
-
|
|
436
|
+
const why = lastStall ? `. ${lastStall.inline}` : '';
|
|
437
|
+
return { ok: false, stop: true, reason: `${current.owner} proposed nothing usable${next}${why}`, ...(lastStall ? { engine: lastStall.engine, cause: lastStall.kind, next_step: lastStall.next } : {}) };
|
|
425
438
|
}
|
|
426
439
|
for (const p of proposals) log(` proposed for ${p.member}: ${p.objective}`);
|
|
427
440
|
return { stop: false, did: 'propose' };
|
|
@@ -445,7 +458,7 @@ function makeLoop(root, options = {}, deps = {}) {
|
|
|
445
458
|
return { steps: maxSteps, reason: `stopped after ${maxSteps} steps`, campaign: view(id) };
|
|
446
459
|
}
|
|
447
460
|
|
|
448
|
-
return { step, tick, run, propose };
|
|
461
|
+
return { step, tick, run, propose, lastStall: () => lastStall };
|
|
449
462
|
}
|
|
450
463
|
|
|
451
464
|
module.exports = { ACTIVITY_LINE, askOwner, briefPath, makeLoop, parseProposals, proposalPrompt, rankCampaignMembers, readBrief, readingKey, runCli };
|
package/lib/campaign-new.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
const fs = require('fs');
|
|
10
10
|
const path = require('path');
|
|
11
11
|
const { newCampaignId } = require('./campaign-store');
|
|
12
|
+
const { engineCannotRun, explainStall } = require('./campaign-engine-stall');
|
|
12
13
|
|
|
13
14
|
const DEFAULT_BUDGET = 20;
|
|
14
15
|
const DEFAULT_OWNER = 'mission-lead';
|
|
@@ -100,10 +101,11 @@ function parseDraft(text, members) {
|
|
|
100
101
|
async function askOwner(root, view, prompt) {
|
|
101
102
|
const { resolveEngineForMember } = require('./member-engine');
|
|
102
103
|
const { runEngineAskJobs } = require('./engine-ask');
|
|
104
|
+
const { askSummary } = require('./campaign-engine-stall');
|
|
103
105
|
const pick = resolveEngineForMember(view.owner, root) || {};
|
|
104
106
|
const engine = (pick.engine && pick.engine.id) || 'claude';
|
|
105
107
|
const [answer] = await runEngineAskJobs([{ engine, model: pick.model || '', prompt, label: 'campaign new judge' }], { root, timeoutMs: ASK_TIMEOUT_MS });
|
|
106
|
-
return
|
|
108
|
+
return askSummary(answer, engine, { no_engine: !(pick.engine && pick.engine.id) });
|
|
107
109
|
}
|
|
108
110
|
|
|
109
111
|
function writeJudge(root, id, script) {
|
|
@@ -135,7 +137,9 @@ async function draftJudge({ root, goal, owner, idFlag, timeout, ask, judge }) {
|
|
|
135
137
|
const draft = parseDraft(answer.text, members);
|
|
136
138
|
if (!draft) {
|
|
137
139
|
previous = 'the reply was not one JSON object with a judge_script';
|
|
138
|
-
last = { error: `${owner}'s engine (${answer.engine || 'unknown'}) did not return a judge script
|
|
140
|
+
last = { error: `${owner}'s engine (${answer.engine || 'unknown'}) did not return a judge script`, answer };
|
|
141
|
+
// Asking again cannot help an engine that never ran.
|
|
142
|
+
if (engineCannotRun(answer)) break;
|
|
139
143
|
continue;
|
|
140
144
|
}
|
|
141
145
|
if (!draft.title) draft.title = goal.slice(0, 80);
|
|
@@ -218,11 +222,18 @@ async function draftAndStart(args, ctx, deps = {}) {
|
|
|
218
222
|
judge: ctx.judge,
|
|
219
223
|
});
|
|
220
224
|
if (!result.ok) {
|
|
225
|
+
const byHand = `write the judge yourself: atris campaign start ${shellQuote(goal.slice(0, 80))} --judge "<command that fails today>" --budget ${budget}`;
|
|
226
|
+
// No draft came back: name the engine and why, then the one next step.
|
|
227
|
+
const stall = result.answer
|
|
228
|
+
? explainStall({ owner, answer: result.answer, wanted: 'a judge script', byHand, again: 'atris campaign new' })
|
|
229
|
+
: null;
|
|
230
|
+
const error = stall ? stall.why : result.error;
|
|
231
|
+
const next = stall ? stall.next : byHand;
|
|
221
232
|
if (ctx.asJson) {
|
|
222
|
-
console.log(JSON.stringify({ ok: false, error: result.
|
|
233
|
+
console.log(JSON.stringify({ ok: false, error, next, engine: stall ? stall.engine : result.engine || null, cause: stall ? stall.kind : null, draft: result.draft || null, reading: result.reading || null }));
|
|
223
234
|
return 1;
|
|
224
235
|
}
|
|
225
|
-
return ctx.fail(`${
|
|
236
|
+
return ctx.fail(`${error}.\nnext: ${next}`, 1);
|
|
226
237
|
}
|
|
227
238
|
|
|
228
239
|
const draft = { id: result.id, title: result.draft.title, judge: result.command, judge_file: result.judgeFile, why: result.draft.why, seats: result.draft.seats, owner, budget, engine: result.engine };
|
package/lib/design-api.js
CHANGED
|
@@ -34,14 +34,25 @@ function readKeyFile(file = DESIGN_KEY_FILE) {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
// Returns the bearer token for design calls, or null when nothing is set up.
|
|
37
|
+
// A developer key (env, then the saved key file) comes before the login token:
|
|
38
|
+
// the design server only accepts developer keys, so a saved key must never be
|
|
39
|
+
// shadowed by being signed in.
|
|
37
40
|
function resolveDesignKey(env = process.env, deps = {}) {
|
|
38
41
|
const fromEnv = env.ATRIS_API_KEY && env.ATRIS_API_KEY.trim();
|
|
39
42
|
if (fromEnv) return fromEnv;
|
|
43
|
+
const fromFile = (deps.readKeyFile || readKeyFile)();
|
|
44
|
+
if (fromFile) return fromFile;
|
|
40
45
|
const load = deps.loadCredentials || loadCredentials;
|
|
41
46
|
const creds = load();
|
|
42
47
|
const token = creds && typeof creds.token === 'string' ? creds.token.trim() : '';
|
|
43
|
-
|
|
44
|
-
|
|
48
|
+
return token || null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A design call that was refused for a sign-in token gets a next step.
|
|
52
|
+
const DESIGN_NEEDS_KEY = 'design tools take a developer key, not your sign-in. make one with atris api-key create, then set ATRIS_API_KEY or save it to ~/.atris/design-api-key';
|
|
53
|
+
|
|
54
|
+
function isDeveloperKey(key) {
|
|
55
|
+
return /^atris_/.test(String(key || '').trim());
|
|
45
56
|
}
|
|
46
57
|
|
|
47
58
|
// One JSON call against the design API. Returns { ok, status, data, error }.
|
|
@@ -68,9 +79,10 @@ async function designRequest(pathname, options = {}) {
|
|
|
68
79
|
let data = null;
|
|
69
80
|
try { data = text ? JSON.parse(text) : null; } catch { data = null; }
|
|
70
81
|
const ok = res.status >= 200 && res.status < 300;
|
|
71
|
-
|
|
82
|
+
let error = !ok
|
|
72
83
|
? (data && typeof data === 'object' && (data.detail || data.error || data.message)) || text.slice(0, 200) || `http ${res.status}`
|
|
73
84
|
: undefined;
|
|
85
|
+
if (res.status === 401 && !isDeveloperKey(key)) error = `${typeof error === 'string' ? error : 'unauthorized'}. ${DESIGN_NEEDS_KEY}`;
|
|
74
86
|
return { ok, status: res.status, data, error };
|
|
75
87
|
}
|
|
76
88
|
|
package/lib/ninety-nine-sync.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const { createHash } = require('node:crypto');
|
|
4
4
|
const { apiRequestJson, getApiBaseUrl } = require('../utils/api');
|
|
5
5
|
const { decodeJwtClaims } = require('../utils/auth');
|
|
6
|
-
const { parseSessionLog, lineIdentity, uniqueLines } = require('./ninety-nine');
|
|
6
|
+
const { parseSessionLog, lineIdentity, uniqueLines, localBlocks, localDate, blocksLine, openDays } = require('./ninety-nine');
|
|
7
7
|
|
|
8
8
|
const WORKSPACE_DIRECTORY = 'atris/99';
|
|
9
9
|
|
|
@@ -153,4 +153,74 @@ async function syncLines({ records, sync, checkpoint = {}, deviceId, credentials
|
|
|
153
153
|
};
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
-
|
|
156
|
+
// The account the day endpoint saves to, whatever the session sync target is.
|
|
157
|
+
function blockAccountKey(credentials) {
|
|
158
|
+
const account = credentials.user_id || decodeJwtClaims(credentials.token)?.sub || credentials.email
|
|
159
|
+
|| createHash('sha256').update(credentials.token).digest('hex');
|
|
160
|
+
return createHash('sha256').update(JSON.stringify([getApiBaseUrl(), account, 'blocks'])).digest('hex');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function blockPrint(block) {
|
|
164
|
+
return createHash('sha256').update(JSON.stringify(block)).digest('hex').slice(0, 16);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const BLOCKS_PER_SAVE = 24;
|
|
168
|
+
const SENT_KEEP_DAYS = 7;
|
|
169
|
+
|
|
170
|
+
// Each finished focus block made here goes once to PUT /api/99/day/{day} under
|
|
171
|
+
// habits.deep_work.blocks, with done, as the phone and web send it. `sent` maps
|
|
172
|
+
// block id to "day|print" of the copy the server took. A save the server refuses
|
|
173
|
+
// for good (400, or 429 for a full day) settles its blocks so one bad block never holds the rest.
|
|
174
|
+
async function syncBlocks({ records, sent = {}, credentials, now = new Date(), dryRun = false, request = apiRequestJson }) {
|
|
175
|
+
const open = new Set(openDays(now));
|
|
176
|
+
const pending = localBlocks(records).filter(({ day, block }) => open.has(day)
|
|
177
|
+
&& sent[block.id] !== `${day}|${blockPrint(block)}`);
|
|
178
|
+
const next = { ...sent };
|
|
179
|
+
const result = { send: pending.map((item) => item.block), sent: next, line: null, refused: null, locked: false };
|
|
180
|
+
if (dryRun || !pending.length) return result;
|
|
181
|
+
const byDay = new Map();
|
|
182
|
+
for (const item of pending) {
|
|
183
|
+
if (!byDay.has(item.day)) byDay.set(item.day, []);
|
|
184
|
+
byDay.get(item.day).push(item);
|
|
185
|
+
}
|
|
186
|
+
for (const [day, items] of byDay) {
|
|
187
|
+
for (let offset = 0; offset < items.length; offset += BLOCKS_PER_SAVE) {
|
|
188
|
+
const batch = items.slice(offset, offset + BLOCKS_PER_SAVE);
|
|
189
|
+
const query = new URLSearchParams({ tz_offset_minutes: String(batch[0].tz_offset_minutes) });
|
|
190
|
+
let response;
|
|
191
|
+
try {
|
|
192
|
+
response = await request(`/99/day/${day}?${query}`, {
|
|
193
|
+
method: 'PUT', token: credentials.token,
|
|
194
|
+
body: { habits: { deep_work: { done: true, blocks: batch.map((item) => item.block) } } },
|
|
195
|
+
});
|
|
196
|
+
} catch {
|
|
197
|
+
throw new Error('could not reach the 99 server; try again');
|
|
198
|
+
}
|
|
199
|
+
if (response?.status === 402) {
|
|
200
|
+
result.locked = true;
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
if ([401, 403].includes(response?.status)) throw new Error('sync needs your signed-in user login; run atris login');
|
|
204
|
+
if ([400, 429].includes(response?.status)) {
|
|
205
|
+
result.refused = String(response?.data?.detail?.error || 'refused').slice(0, 40);
|
|
206
|
+
} else if (!response?.ok) {
|
|
207
|
+
throw new Error(`the 99 server did not take the focus blocks${response?.status ? ` (http ${response.status})` : ''}; try again`);
|
|
208
|
+
} else {
|
|
209
|
+
const deep = response.data?.habits?.deep_work;
|
|
210
|
+
if (Number.isInteger(deep?.block_count) && Number.isInteger(deep?.block_minutes)) {
|
|
211
|
+
result.line = blocksLine({ count: deep.block_count, minutes: deep.block_minutes });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
for (const item of batch) next[item.block.id] = `${item.day}|${blockPrint(item.block)}`;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const oldest = new Date(now);
|
|
218
|
+
oldest.setDate(oldest.getDate() - SENT_KEEP_DAYS);
|
|
219
|
+
const cutoff = localDate(oldest);
|
|
220
|
+
for (const [id, value] of Object.entries(next)) {
|
|
221
|
+
if (typeof value !== 'string' || value.slice(0, 10) < cutoff) delete next[id];
|
|
222
|
+
}
|
|
223
|
+
return result;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = { validateSync, checkpointKey, syncLines, syncBlocks, blockAccountKey };
|
package/lib/ninety-nine.js
CHANGED
|
@@ -4,6 +4,10 @@ const LENGTHS = { focus: [25, 50, 90], meditate: [10, 20, 30] };
|
|
|
4
4
|
const SOURCES = ['cli', 'mac', 'ios'];
|
|
5
5
|
const PAUSE_LIMIT_MS = 30 * 60000;
|
|
6
6
|
const HEALTH_FIELDS = ['sleep_min', 'steps', 'workout_min', 'mindful_min'];
|
|
7
|
+
const OUTCOMES = ['yes', 'partly', 'no'];
|
|
8
|
+
const INTENTION_MAX = 200;
|
|
9
|
+
const BLOCK_NOTE_MAX = 500;
|
|
10
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
7
11
|
|
|
8
12
|
function lineIdentity(record) {
|
|
9
13
|
return `${record.type}|${record.type === 'health_day' ? record.date : record.id}|${record.at}|${record.source}`;
|
|
@@ -19,6 +23,14 @@ function uniqueLines(records) {
|
|
|
19
23
|
});
|
|
20
24
|
}
|
|
21
25
|
|
|
26
|
+
function oneLineText(value) {
|
|
27
|
+
return typeof value === 'string' && !/[\r\n\u2028\u2029]/.test(value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function oneLine(value, limit) {
|
|
31
|
+
return String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, limit).trim();
|
|
32
|
+
}
|
|
33
|
+
|
|
22
34
|
function validLength(kind, minutes) {
|
|
23
35
|
return Object.hasOwn(LENGTHS, kind) && LENGTHS[kind].includes(minutes);
|
|
24
36
|
}
|
|
@@ -31,10 +43,12 @@ function parseSessionLog(text) {
|
|
|
31
43
|
const common = record && record.v === 1 && SOURCES.includes(record.source)
|
|
32
44
|
&& typeof record.at === 'string' && Number.isFinite(Date.parse(record.at));
|
|
33
45
|
const session = typeof record?.id === 'string' && record.id;
|
|
34
|
-
const start = record?.type === 'start' && validLength(record.kind, record.planned_min)
|
|
46
|
+
const start = record?.type === 'start' && validLength(record.kind, record.planned_min)
|
|
47
|
+
&& (record.intention === undefined || oneLineText(record.intention));
|
|
35
48
|
const end = record?.type === 'end' && ['done', 'partial'].includes(record.status)
|
|
36
49
|
&& (record.inferred === undefined || typeof record.inferred === 'boolean')
|
|
37
|
-
&& (record.note === undefined || (
|
|
50
|
+
&& (record.note === undefined || oneLineText(record.note))
|
|
51
|
+
&& (record.outcome === undefined || record.outcome === null || OUTCOMES.includes(record.outcome));
|
|
38
52
|
const pause = ['pause', 'resume'].includes(record?.type);
|
|
39
53
|
const health = record?.type === 'health_day' && typeof record.date === 'string'
|
|
40
54
|
&& /^\d{4}-\d{2}-\d{2}$/.test(record.date)
|
|
@@ -54,6 +68,7 @@ function foldSessions(records) {
|
|
|
54
68
|
const sessions = new Map();
|
|
55
69
|
const events = new Map();
|
|
56
70
|
const ends = new Map();
|
|
71
|
+
const outcomes = new Map();
|
|
57
72
|
// Stable sort preserves append order when timestamps tie, without rewriting the log.
|
|
58
73
|
for (const record of uniqueLines(records).sort((a, b) => Date.parse(a.at) - Date.parse(b.at))) {
|
|
59
74
|
if (record.type === 'health_day') continue;
|
|
@@ -68,6 +83,7 @@ function foldSessions(records) {
|
|
|
68
83
|
ended_at: null,
|
|
69
84
|
status: 'running',
|
|
70
85
|
});
|
|
86
|
+
if (record.intention) sessions.get(record.id).intention = record.intention;
|
|
71
87
|
events.set(record.id, []);
|
|
72
88
|
} else {
|
|
73
89
|
const session = sessions.get(record.id);
|
|
@@ -76,6 +92,11 @@ function foldSessions(records) {
|
|
|
76
92
|
events.get(record.id).push(record);
|
|
77
93
|
continue;
|
|
78
94
|
}
|
|
95
|
+
// How the block went counts from any end record: the newest answer wins, and
|
|
96
|
+
// null (asked, skipped) only stands when there is no answer at all.
|
|
97
|
+
if (record.outcome !== undefined && (record.outcome !== null || !outcomes.has(record.id))) {
|
|
98
|
+
outcomes.set(record.id, record.outcome);
|
|
99
|
+
}
|
|
79
100
|
const previous = ends.get(record.id);
|
|
80
101
|
// A person's end wins over an automatic observation, in either log order.
|
|
81
102
|
if (!previous || (previous.inferred === true && record.inferred !== true)
|
|
@@ -115,6 +136,7 @@ function foldSessions(records) {
|
|
|
115
136
|
session.status = end.status;
|
|
116
137
|
if (end.note !== undefined) session.note = end.note;
|
|
117
138
|
if (end.inferred === true) session.inferred = true;
|
|
139
|
+
if (outcomes.has(session.id)) session.outcome = outcomes.get(session.id);
|
|
118
140
|
}
|
|
119
141
|
}
|
|
120
142
|
return [...sessions.values()];
|
|
@@ -127,9 +149,50 @@ function runningSession(sessions, now) {
|
|
|
127
149
|
return running[0] || null;
|
|
128
150
|
}
|
|
129
151
|
|
|
130
|
-
function startRecord(kind, plannedMin, now, id) {
|
|
152
|
+
function startRecord(kind, plannedMin, now, id, intention = '') {
|
|
131
153
|
if (!validLength(kind, plannedMin)) throw new Error('choose a supported session length');
|
|
132
|
-
|
|
154
|
+
const record = { v: 1, type: 'start', id, kind, planned_min: plannedMin, at: now.toISOString(), source: 'cli' };
|
|
155
|
+
const line = kind === 'focus' ? oneLine(intention, INTENTION_MAX) : '';
|
|
156
|
+
if (line) record.intention = line;
|
|
157
|
+
return record;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// How a finished focus block went: yes, partly, no, or null for asked and skipped.
|
|
161
|
+
// Only a focus block that ran its full time carries it.
|
|
162
|
+
function withOutcome(record, session, outcome) {
|
|
163
|
+
if ((outcome === null || OUTCOMES.includes(outcome)) && session.kind === 'focus' && record.status === 'done') {
|
|
164
|
+
record.outcome = outcome;
|
|
165
|
+
}
|
|
166
|
+
return record;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// One more end line that adds the answer to a block that already ended. It sits after
|
|
170
|
+
// the real end, so every face keeps the earliest end and its time; only the answer is new.
|
|
171
|
+
function answerRecord(session, now, outcome) {
|
|
172
|
+
const at = Math.max(now.getTime(), Date.parse(session.ended_at) + 1);
|
|
173
|
+
return {
|
|
174
|
+
v: 1, type: 'end', id: session.id, status: 'done', at: new Date(at).toISOString(), source: 'cli', inferred: true, outcome,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// The server takes saves for the local today, and for yesterday until 3 in the morning.
|
|
179
|
+
function openDays(now) {
|
|
180
|
+
const days = [localDate(now)];
|
|
181
|
+
if (now.getHours() < 3) {
|
|
182
|
+
const yesterday = new Date(now);
|
|
183
|
+
yesterday.setDate(yesterday.getDate() - 1);
|
|
184
|
+
days.push(localDate(yesterday));
|
|
185
|
+
}
|
|
186
|
+
return days;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Finished focus blocks made on this computer, still open for a save, never asked about.
|
|
190
|
+
function unansweredBlocks(records, now) {
|
|
191
|
+
const sources = new Map(records.filter((record) => record.type === 'start').map((record) => [record.id, record.source]));
|
|
192
|
+
const open = new Set(openDays(now));
|
|
193
|
+
return foldSessions(records).filter((session) => session.kind === 'focus' && session.status === 'done'
|
|
194
|
+
&& session.outcome === undefined && ['cli', 'mac'].includes(sources.get(session.id))
|
|
195
|
+
&& open.has(localDate(new Date(session.started_at))));
|
|
133
196
|
}
|
|
134
197
|
|
|
135
198
|
function endRecord(session, now, note = '') {
|
|
@@ -208,6 +271,72 @@ function sessionsToday(sessions, now) {
|
|
|
208
271
|
};
|
|
209
272
|
}
|
|
210
273
|
|
|
274
|
+
// A focus block counts on the local day it started, as on the phone.
|
|
275
|
+
function blocksToday(sessions, now) {
|
|
276
|
+
const date = localDate(now);
|
|
277
|
+
const blocks = sessions.filter((session) => session.kind === 'focus' && session.status === 'done'
|
|
278
|
+
&& localDate(new Date(session.started_at)) === date);
|
|
279
|
+
return { count: blocks.length, minutes: blocks.reduce((sum, session) => sum + session.planned_min, 0) };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 125 is "2 hours 5 minutes", 60 is "1 hour", 25 is "25 minutes".
|
|
283
|
+
function blockDuration(minutes) {
|
|
284
|
+
const hours = Math.floor(minutes / 60);
|
|
285
|
+
const rest = minutes % 60;
|
|
286
|
+
const hourText = hours === 1 ? '1 hour' : `${hours} hours`;
|
|
287
|
+
const minuteText = rest === 1 ? '1 minute' : `${rest} minutes`;
|
|
288
|
+
if (hours === 0) return minuteText;
|
|
289
|
+
return rest === 0 ? hourText : `${hourText} ${minuteText}`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// "3 blocks, 2 hours 5 minutes"; null with no finished block.
|
|
293
|
+
function blocksLine({ count, minutes }) {
|
|
294
|
+
if (!count) return null;
|
|
295
|
+
return `${count === 1 ? '1 block' : `${count} blocks`}, ${blockDuration(minutes)}`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function offsetMinutesAt(date) {
|
|
299
|
+
return -date.getTimezoneOffset();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ISO 8601 with the local offset at that moment, whole seconds, like the phone writes.
|
|
303
|
+
function isoWithOffset(date) {
|
|
304
|
+
const offset = offsetMinutesAt(date);
|
|
305
|
+
const local = new Date(date.getTime() + offset * 60000).toISOString().slice(0, 19);
|
|
306
|
+
if (offset === 0) return `${local}Z`;
|
|
307
|
+
const sign = offset < 0 ? '-' : '+';
|
|
308
|
+
const abs = Math.abs(offset);
|
|
309
|
+
return `${local}${sign}${String(Math.floor(abs / 60)).padStart(2, '0')}:${String(abs % 60).padStart(2, '0')}`;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// A finished focus block in the shape of "Contract: blocks" in project99's deep-work idea.
|
|
313
|
+
// Only blocks made on this computer go; the phone sends its own.
|
|
314
|
+
function blockFromSession(session) {
|
|
315
|
+
if (session.kind !== 'focus' || session.status !== 'done' || !UUID.test(session.id)) return null;
|
|
316
|
+
const started = new Date(session.started_at);
|
|
317
|
+
return {
|
|
318
|
+
day: localDate(started),
|
|
319
|
+
tz_offset_minutes: offsetMinutesAt(started),
|
|
320
|
+
block: {
|
|
321
|
+
id: session.id,
|
|
322
|
+
minutes: session.planned_min,
|
|
323
|
+
intention: oneLine(session.intention, INTENTION_MAX),
|
|
324
|
+
started_at: isoWithOffset(started),
|
|
325
|
+
ended_at: isoWithOffset(new Date(session.ended_at)),
|
|
326
|
+
outcome: session.outcome ?? null,
|
|
327
|
+
note: oneLine(session.note, BLOCK_NOTE_MAX),
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function localBlocks(records) {
|
|
333
|
+
const sources = new Map(records.filter((record) => record.type === 'start').map((record) => [record.id, record.source]));
|
|
334
|
+
return foldSessions(records)
|
|
335
|
+
.filter((session) => ['cli', 'mac'].includes(sources.get(session.id)))
|
|
336
|
+
.map(blockFromSession)
|
|
337
|
+
.filter(Boolean);
|
|
338
|
+
}
|
|
339
|
+
|
|
211
340
|
function healthToday(records, now) {
|
|
212
341
|
const date = localDate(now);
|
|
213
342
|
let latest;
|
|
@@ -228,6 +357,21 @@ function healthSummary(record) {
|
|
|
228
357
|
}
|
|
229
358
|
|
|
230
359
|
module.exports = {
|
|
360
|
+
OUTCOMES,
|
|
361
|
+
INTENTION_MAX,
|
|
362
|
+
BLOCK_NOTE_MAX,
|
|
363
|
+
oneLine,
|
|
364
|
+
localDate,
|
|
365
|
+
withOutcome,
|
|
366
|
+
answerRecord,
|
|
367
|
+
openDays,
|
|
368
|
+
unansweredBlocks,
|
|
369
|
+
blocksToday,
|
|
370
|
+
blockDuration,
|
|
371
|
+
blocksLine,
|
|
372
|
+
isoWithOffset,
|
|
373
|
+
blockFromSession,
|
|
374
|
+
localBlocks,
|
|
231
375
|
validLength,
|
|
232
376
|
parseSessionLog,
|
|
233
377
|
foldSessions,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "atris",
|
|
3
|
-
"version": "3.82.
|
|
3
|
+
"version": "3.82.18",
|
|
4
4
|
"description": "you say what you want in plain words. atris builds it, checks it, and shows you proof. project memory your coding agents share across sessions.",
|
|
5
5
|
"mcpName": "io.github.atrislabs/atris",
|
|
6
6
|
"main": "bin/atris.js",
|