atris 3.82.15 → 3.82.17
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/bin/atris.js +10 -6
- package/commands/agent-client.js +51 -5
- package/commands/auth.js +149 -35
- package/commands/campaign.js +3 -1
- package/commands/ninety-nine.js +247 -24
- package/commands/usage.js +13 -3
- 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/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.17",
|
|
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",
|