atris 3.71.2 → 3.71.3

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.
@@ -11,6 +11,7 @@ const fs = require('fs');
11
11
  const path = require('path');
12
12
  const { paysDollars } = require('./campaign-core');
13
13
  const { getCampaign, openCampaignStore } = require('./campaign-store');
14
+ const { readingKey } = require('./campaign-loop');
14
15
 
15
16
  const LESSON_LINES_FOR_PLANNING = 30;
16
17
 
@@ -26,16 +27,35 @@ function lessonsPath(root, campaignId) {
26
27
  return path.join(root, 'atris', 'campaigns', campaignId, 'lessons.md');
27
28
  }
28
29
 
29
- // The newest lessons, for the planner.
30
- function readLessons(root, campaignId, lines = LESSON_LINES_FOR_PLANNING) {
30
+ function globalLessonsPath(root) {
31
+ return path.join(root, 'atris', 'campaigns', 'lessons.md');
32
+ }
33
+
34
+ function parseBulletLines(filePath) {
31
35
  try {
32
- const text = fs.readFileSync(lessonsPath(root, campaignId), 'utf8');
33
- return text.split('\n').filter((line) => line.trim().startsWith('- ')).slice(-lines).join('\n');
36
+ const text = fs.readFileSync(filePath, 'utf8');
37
+ return text.split('\n').filter((line) => line.trim().startsWith('- '));
34
38
  } catch {
35
- return '';
39
+ return [];
36
40
  }
37
41
  }
38
42
 
43
+ // The newest lessons for the planner: local campaign lessons plus deduplicated
44
+ // cross-campaign RSI lessons so every new campaign inherits earlier lessons.
45
+ function readLessons(root, campaignId, lines = LESSON_LINES_FOR_PLANNING) {
46
+ const local = parseBulletLines(lessonsPath(root, campaignId));
47
+ const global = parseBulletLines(globalLessonsPath(root));
48
+ const seen = new Set();
49
+ const merged = [];
50
+ for (const line of [...local, ...global]) {
51
+ const key = line.trim().toLowerCase();
52
+ if (seen.has(key)) continue;
53
+ seen.add(key);
54
+ merged.push(line);
55
+ }
56
+ return merged.slice(-lines).join('\n');
57
+ }
58
+
39
59
  function roundSummary(before, after) {
40
60
  const fresh = after.missions.filter((m) => !before.missions.some((b) => b.id === m.id && b.status === m.status));
41
61
  const kept = fresh.filter((m) => m.status === 'ended' && m.outcome === 'kept').length;
@@ -45,6 +65,12 @@ function roundSummary(before, after) {
45
65
  return { kept, failed, vetoed, spent, fresh };
46
66
  }
47
67
 
68
+ // Nothing finished and the check reads the same: there is nothing to learn.
69
+ function quietRound(before, after, summary) {
70
+ const key = (v) => (v.last_judge ? readingKey(v.last_judge.passed, v.last_judge.reading, v.answer.text) : null);
71
+ return !summary.fresh.some((m) => m.status === 'ended' || m.status === 'vetoed') && key(before) === key(after);
72
+ }
73
+
48
74
  // On a subscription the round is told without dollars, since none were paid.
49
75
  function roundTally(view, summary) {
50
76
  return `${summary.kept} kept, ${summary.failed} failed${paysDollars(view) ? `, $${summary.spent} spent` : ''}`;
@@ -82,9 +108,10 @@ function makeCadence(root, loop, options = {}, deps = {}) {
82
108
  async function review(id, before) {
83
109
  const after = getCampaign(store, id);
84
110
  const summary = roundSummary(before, after);
111
+ const quiet = quietRound(before, after, summary);
85
112
  const lessons = readLessons(root, id, 200);
86
113
  let written = [];
87
- if (ask) {
114
+ if (ask && !quiet) {
88
115
  const answer = await ask(root, after, reviewPrompt(after, summary, lessons));
89
116
  // A lesson already on file is not written twice.
90
117
  const known = new Set(lessons.split('\n').map((line) => line.trim().toLowerCase()));
@@ -96,8 +123,10 @@ function makeCadence(root, loop, options = {}, deps = {}) {
96
123
  if (!fs.existsSync(file)) fs.writeFileSync(file, `# Lessons: ${after.title}\n\nWritten by the owner at each review; the next round plans from them.\n`);
97
124
  const stamp = new Date(now()).toISOString().slice(0, 16).replace('T', ' ');
98
125
  const header = `\n## ${stamp}: ${roundTally(after, summary)}\n`;
99
- fs.appendFileSync(file, `${header}${(written.length ? written : ['- (no review written this round)']).join('\n')}\n`);
100
- return { summary, lessons: written, view: after };
126
+ // A quiet round is noted as plain text, so the planner never reads it as a lesson.
127
+ const body = quiet ? ['(no review written this round: nothing ended and the reading held)'] : (written.length ? written : ['- (no review written this round)']);
128
+ fs.appendFileSync(file, `${header}${body.join('\n')}\n`);
129
+ return { summary, lessons: written, view: after, quiet };
101
130
  }
102
131
 
103
132
  async function run(id) {
@@ -118,8 +147,8 @@ function makeCadence(root, loop, options = {}, deps = {}) {
118
147
  }
119
148
  const breakStarted = now();
120
149
  log(`round ${round}: review`);
121
- const { summary, lessons, view: reviewed } = await review(id, before);
122
- log(` ${roundTally(reviewed, summary)}; ${lessons.length} lesson${lessons.length === 1 ? '' : 's'} written`);
150
+ const { summary, lessons, view: reviewed, quiet } = await review(id, before);
151
+ log(quiet ? ' nothing new this round; no review' : ` ${roundTally(reviewed, summary)}; ${lessons.length} lesson${lessons.length === 1 ? '' : 's'} written`);
123
152
  const view = getCampaign(store, id);
124
153
  last = { reason: stopped || 'work window ended', round };
125
154
  // Finished, out of money, or waiting on a person: no point in another round.
@@ -60,7 +60,35 @@ function newMission(id) {
60
60
  }
61
61
 
62
62
  // Returns the folded view, or { error } when the events must be refused.
63
+ // A judge reading can carry a saved wait: nothing to do until wake_at, then
64
+ // check the judge again. It rides as extra data on judge_ran so a reader that
65
+ // predates it still folds the campaign. Anything new (a mission, an answer, a
66
+ // new judge, more money, a resume) ends the wait.
67
+ const WAIT_ENDS = new Set(['mission_proposed', 'mission_claimed', 'mission_started', 'mission_ended', 'judge_set', 'ask_answered', 'resumed', 'budget_added']);
68
+
69
+ function savedWait(data) {
70
+ const w = data.wait;
71
+ if (data.passed || !w || !Number.isFinite(w.wake_at) || !(w.wait_ms > 0)) return null;
72
+ return { wake_at: w.wake_at, wait_ms: w.wait_ms };
73
+ }
74
+
63
75
  function fold(events) {
76
+ return foldEvents(events);
77
+ }
78
+
79
+ // Internal consumers can project selected canonical prefix counts in the same
80
+ // traversal. The synchronous projector is trusted and read-only; only its
81
+ // result is retained, so callers need not keep every full historical view.
82
+ function foldWithPrefixes(events, prefixLengths, project = (view) => view) {
83
+ const requested = new Set(prefixLengths.filter((n) => Number.isSafeInteger(n) && n >= 1));
84
+ const prefixes = new Map();
85
+ const view = foldEvents(events, (state, start, owner, count) => {
86
+ if (requested.has(count)) prefixes.set(count, project(renderView(state, start, owner), count));
87
+ });
88
+ return view.error ? view : { view, prefixes };
89
+ }
90
+
91
+ function foldEvents(events, visit) {
64
92
  if (!Array.isArray(events)) return { error: 'invalid_event' };
65
93
  for (const event of events) {
66
94
  if (!validateEvent(event).ok) return { error: 'invalid_event' };
@@ -94,9 +122,12 @@ function fold(events) {
94
122
  asks: new Map(),
95
123
  judgeRuns: [],
96
124
  lastEndedPos: -1,
125
+ wait: null,
97
126
  judgeSetPos: -1,
98
127
  judge: start.data.judge,
99
128
  lastJudgePos: -1,
129
+ // Jobs that did real work and ended since the last reading, waiting on it.
130
+ awaitingReading: [],
100
131
  warnings: [],
101
132
  };
102
133
  const d = start.data;
@@ -113,6 +144,7 @@ function fold(events) {
113
144
  const sequence = [start, ...ordered.filter((e) => e !== start)];
114
145
  sequence.forEach((event, pos) => {
115
146
  const data = event.data;
147
+ if (WAIT_ENDS.has(event.type)) s.wait = null;
116
148
  switch (event.type) {
117
149
  case 'campaign_started':
118
150
  if (event !== start) warn(event, 'duplicate_start');
@@ -129,6 +161,7 @@ function fold(events) {
129
161
  } else {
130
162
  s.judge = data.judge;
131
163
  s.judgeSetPos = pos;
164
+ s.awaitingReading = [];
132
165
  }
133
166
  break;
134
167
  case 'attached':
@@ -181,6 +214,8 @@ function fold(events) {
181
214
  const m = mission(data.mission_id);
182
215
  m.status = 'ended';
183
216
  m.outcome = data.outcome;
217
+ if (data.outcome === 'fault' && typeof data.reason === 'string') m.reason = data.reason;
218
+ if (m.steps > 0 && data.outcome !== 'kept' && data.outcome !== 'fault') s.awaitingReading.push(m);
184
219
  m.costCents = cents(data.cost_usd);
185
220
  s.spentCents += m.costCents;
186
221
  s.reservations.delete(data.mission_id);
@@ -207,6 +242,11 @@ function fold(events) {
207
242
  at: event.at,
208
243
  });
209
244
  s.lastJudgePos = pos;
245
+ s.wait = savedWait(data);
246
+ // A job that worked but stopped short is credited when the very next
247
+ // reading passes: its work is what moved the check.
248
+ if (data.passed) for (const m of s.awaitingReading) m.credited = true;
249
+ s.awaitingReading = [];
210
250
  if (data.passed && !d.standing && !s.finished) s.finished = 'judge_passed';
211
251
  break;
212
252
  case 'ask_opened':
@@ -236,8 +276,14 @@ function fold(events) {
236
276
  default:
237
277
  break;
238
278
  }
279
+ if (visit) visit(s, start, owner, pos + 1);
239
280
  });
240
281
 
282
+ return renderView(s, start, owner);
283
+ }
284
+
285
+ function renderView(s, start, owner) {
286
+ const d = start.data;
241
287
  const missions = [...s.missions.values()];
242
288
  let state;
243
289
  let stopReason = null;
@@ -279,14 +325,14 @@ function fold(events) {
279
325
  judge: s.judge,
280
326
  owner,
281
327
  standing: d.standing === true,
282
- bet: s.bet,
328
+ bet: s.bet ? { ...s.bet } : null,
283
329
  parent: s.parent,
284
330
  home: s.home,
285
331
  // Only when the start event names one, so the shared golden views stay as
286
332
  // pinned; no basis means the person's own engines.
287
333
  ...(d.basis === 'atris_cloud' || d.basis === 'own_engines' ? { basis: d.basis } : {}),
288
334
  created_from: d.created_from === undefined ? null : d.created_from,
289
- seats: s.seats,
335
+ seats: s.seats.map((seat) => ({ ...seat })),
290
336
  state,
291
337
  stop_reason: stopReason,
292
338
  answer: { text: latest ? latest.summary : null, confidence },
@@ -296,7 +342,9 @@ function fold(events) {
296
342
  reserved: dollars(reservedCents),
297
343
  available: dollars(s.totalCents - s.spentCents - reservedCents),
298
344
  },
299
- asks: [...s.asks.values()],
345
+ asks: [...s.asks.values()].map((ask) => ({ ...ask })),
346
+ // Only while a wait is saved, so the shared golden views stay as pinned.
347
+ ...(s.wait ? { next_move: { action: 'wait', reason: 'waiting on the world', ...s.wait } } : {}),
300
348
  missions: missions.map((m) => ({
301
349
  id: m.id,
302
350
  member: m.member,
@@ -307,11 +355,13 @@ function fold(events) {
307
355
  steps: m.steps,
308
356
  kept: m.kept,
309
357
  reverted: m.reverted,
358
+ ...(m.reason ? { reason: m.reason } : {}),
359
+ ...(m.credited ? { credited: true } : {}),
310
360
  })),
311
361
  judge_runs: runs.length,
312
362
  judge_due: runs.length === 0 || Math.max(s.lastEndedPos, s.judgeSetPos) > s.lastJudgePos,
313
363
  last_judge: latest ? { passed: latest.passed, reading: latest.reading, at: latest.at } : null,
314
- warnings: s.warnings,
364
+ warnings: s.warnings.map((warning) => ({ ...warning })),
315
365
  };
316
366
  }
317
367
 
@@ -319,9 +369,13 @@ function decide(view) {
319
369
  if (view.state === 'done') return { action: 'none', reason: 'finished' };
320
370
  if (view.state === 'out_of_budget') return { action: 'none', reason: 'budget spent' };
321
371
  if (view.state === 'paused') return { action: 'none', reason: 'paused' };
322
- if (view.asks.length > 0) return { action: 'wait', reason: 'open asks', asks: view.asks.map((a) => a.id) };
372
+ // An open ask holds only the mission it names. When every claimed mission is
373
+ // held, the campaign waits for the person; an ask that names no mission holds nothing.
374
+ const held = new Set(view.asks.map((a) => a.mission_id).filter(Boolean));
375
+ const queued = view.missions.filter((m) => m.status === 'claimed');
376
+ const claimed = queued.filter((m) => !held.has(m.id));
377
+ if (queued.length > 0 && claimed.length === 0) return { action: 'wait', reason: 'open asks', asks: view.asks.map((a) => a.id) };
323
378
  if (view.judge_due) return { action: 'run_judge' };
324
- const claimed = view.missions.filter((m) => m.status === 'claimed');
325
379
  const available = cents(view.budget.available);
326
380
  const fits = claimed.find((m) => cents(m.estimate_usd || 0) <= available);
327
381
  if (fits) return { action: 'start_mission', mission_id: fits.id };
@@ -338,4 +392,4 @@ function paysDollars(view) {
338
392
  return Boolean(view && view.basis === 'atris_cloud');
339
393
  }
340
394
 
341
- module.exports = { cents, compareEvents, decide, fold, paysDollars };
395
+ module.exports = { cents, compareEvents, decide, fold, foldWithPrefixes, paysDollars };
@@ -0,0 +1,91 @@
1
+ // atris campaign keep: a one-minute timer that runs `atris campaign wake`,
2
+ // so a campaign's promise to check again holds while no terminal is open
3
+ // (atris/features/campaign/promise.md). launchd on macOS, cron elsewhere.
4
+
5
+ 'use strict';
6
+
7
+ const crypto = require('crypto');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const { spawnSync } = require('child_process');
12
+
13
+ const MARKER = 'ATRIS_CAMPAIGN_WAKE';
14
+
15
+ function xml(text) {
16
+ return String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
17
+ }
18
+
19
+ function shellQuote(text) {
20
+ return `'${String(text).replace(/'/g, `'\\''`)}'`;
21
+ }
22
+
23
+ // One timer per checkout: the label and marker carry a short hash of its root.
24
+ function planKeep({ root, home, platform = process.platform, homeDir = os.homedir(), nodeBin = 'node', atrisBin, envPath = process.env.PATH || '' }) {
25
+ const slot = crypto.createHash('sha256').update(root).digest('hex').slice(0, 8);
26
+ // node by name on the saved PATH, so upgrading node does not break the timer.
27
+ const command = ['/usr/bin/env', nodeBin, atrisBin, 'campaign', 'wake', '--json'];
28
+ const logPath = path.join(home, 'campaign-wake.log');
29
+ const base = { root, slot, log_path: logPath, command: command.join(' '), every_seconds: 60 };
30
+ if (platform === 'darwin') {
31
+ const label = `ai.atris.campaign-wake.${slot}`;
32
+ const file = path.join(homeDir, 'Library', 'LaunchAgents', `${label}.plist`);
33
+ const contents = `<?xml version="1.0" encoding="UTF-8"?>
34
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
35
+ <plist version="1.0">
36
+ <dict>
37
+ <key>Label</key><string>${xml(label)}</string>
38
+ <key>ProgramArguments</key>
39
+ <array>${command.map((part) => `<string>${xml(part)}</string>`).join('')}</array>
40
+ <key>WorkingDirectory</key><string>${xml(root)}</string>
41
+ <key>EnvironmentVariables</key>
42
+ <dict><key>PATH</key><string>${xml(envPath)}</string><key>ATRIS_SKIP_UPDATE_CHECK</key><string>1</string></dict>
43
+ <key>StartInterval</key><integer>60</integer>
44
+ <key>StandardOutPath</key><string>${xml(logPath)}</string>
45
+ <key>StandardErrorPath</key><string>${xml(logPath)}</string>
46
+ </dict>
47
+ </plist>
48
+ `;
49
+ return { ...base, kind: 'launchd', label, file, contents };
50
+ }
51
+ const marker = `${MARKER}_${slot}`;
52
+ const line = `* * * * * cd ${shellQuote(root)} && PATH=${shellQuote(envPath)} ATRIS_SKIP_UPDATE_CHECK=1 ${command.map(shellQuote).join(' ')} >> ${shellQuote(logPath)} 2>&1 # ${marker}`;
53
+ return { ...base, kind: 'cron', marker, crontab_line: line };
54
+ }
55
+
56
+ function withoutMarker(crontab, marker) {
57
+ return String(crontab || '').split('\n').filter((l) => l.trim() && !l.includes(marker)).join('\n');
58
+ }
59
+
60
+ // Replaces any timer this checkout already has. Returns { ok, error? }.
61
+ function installKeep(plan, { spawn = spawnSync, uid = process.getuid && process.getuid() } = {}) {
62
+ fs.mkdirSync(path.dirname(plan.log_path), { recursive: true });
63
+ if (plan.kind === 'launchd') {
64
+ fs.mkdirSync(path.dirname(plan.file), { recursive: true });
65
+ fs.writeFileSync(plan.file, plan.contents, 'utf8');
66
+ spawn('launchctl', ['bootout', `gui/${uid}/${plan.label}`], { encoding: 'utf8', timeout: 10000 });
67
+ const r = spawn('launchctl', ['bootstrap', `gui/${uid}`, plan.file], { encoding: 'utf8', timeout: 10000 });
68
+ return r.status === 0 ? { ok: true } : { ok: false, error: `launchctl bootstrap failed: ${String(r.stderr || r.stdout || '').trim()}` };
69
+ }
70
+ const existing = spawn('crontab', ['-l'], { encoding: 'utf8', timeout: 10000 });
71
+ const kept = withoutMarker(existing.status === 0 ? existing.stdout : '', plan.marker);
72
+ const r = spawn('crontab', ['-'], { input: `${kept ? `${kept}\n` : ''}${plan.crontab_line}\n`, encoding: 'utf8', timeout: 10000 });
73
+ return r.status === 0 ? { ok: true } : { ok: false, error: `crontab failed: ${String(r.stderr || r.stdout || '').trim()}` };
74
+ }
75
+
76
+ function removeKeep(plan, { spawn = spawnSync, uid = process.getuid && process.getuid() } = {}) {
77
+ if (plan.kind === 'launchd') {
78
+ spawn('launchctl', ['bootout', `gui/${uid}/${plan.label}`], { encoding: 'utf8', timeout: 10000 });
79
+ const had = fs.existsSync(plan.file);
80
+ if (had) fs.unlinkSync(plan.file);
81
+ return { ok: true, removed: had };
82
+ }
83
+ const existing = spawn('crontab', ['-l'], { encoding: 'utf8', timeout: 10000 });
84
+ const prior = existing.status === 0 ? String(existing.stdout || '') : '';
85
+ if (!prior.includes(plan.marker)) return { ok: true, removed: false };
86
+ const kept = withoutMarker(prior, plan.marker);
87
+ const r = spawn('crontab', ['-'], { input: kept ? `${kept}\n` : '', encoding: 'utf8', timeout: 10000 });
88
+ return r.status === 0 ? { ok: true, removed: true } : { ok: false, error: `crontab failed: ${String(r.stderr || r.stdout || '').trim()}` };
89
+ }
90
+
91
+ module.exports = { MARKER, installKeep, planKeep, removeKeep };
@@ -32,15 +32,20 @@ function approvedLanes(events) {
32
32
  return lanes;
33
33
  }
34
34
 
35
- // True when this campaign already has an open ask for the lane, so the loop asks once.
36
- function laneAskOpen(events, lane) {
37
- const open = new Set();
35
+ // Lanes with an open ask in this campaign.
36
+ function openLaneAsks(events) {
37
+ const open = new Map();
38
38
  for (const event of events || []) {
39
39
  const data = event && event.data ? event.data : {};
40
- if (event.type === 'ask_opened' && data.kind === LANE_ASK_KIND && data.lane === lane) open.add(data.ask_id);
40
+ if (event.type === 'ask_opened' && data.kind === LANE_ASK_KIND && data.lane) open.set(data.ask_id, data.lane);
41
41
  if (event.type === 'ask_answered') open.delete(data.ask_id);
42
42
  }
43
- return open.size > 0;
43
+ return new Set(open.values());
44
+ }
45
+
46
+ // True when this campaign already has an open ask for the lane, so the loop asks once.
47
+ function laneAskOpen(events, lane) {
48
+ return openLaneAsks(events).has(lane);
44
49
  }
45
50
 
46
51
  function laneQuestion(lane, member, objective) {
@@ -49,4 +54,4 @@ function laneQuestion(lane, member, objective) {
49
54
  return `${member}'s mission touches ${lane}: "${short}". Missions that touch ${lane} wait for you. Let this campaign work on ${lane}? Answer yes to allow it.`;
50
55
  }
51
56
 
52
- module.exports = { LANE_ASK_KIND, pausedLane, approvedLanes, laneAskOpen, laneQuestion };
57
+ module.exports = { LANE_ASK_KIND, pausedLane, approvedLanes, laneAskOpen, laneQuestion, openLaneAsks };