jaitus 0.3.12 → 0.3.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/rewind.js CHANGED
@@ -1,148 +1,149 @@
1
- // ─── Rewind: go back to any earlier turn ────────────────────────────────────────
2
- //
3
- // Esc+Esc rewinds the conversation to a previous point — and restores the files as
4
- // they were. The mechanism is simple and honest: before each user prompt
5
- // is processed, snapshot the message history; before any write/edit command the agent
6
- // makes, snapshot the file it is about to touch. Restoring a checkpoint then means
7
- // truncating the message array and copying the file bytes back.
8
- //
9
- // Snapshots live in memory for the session's life: this is undo, not backup. Files are
10
- // capped (count and size) so one `run_command` that rewrites a giant file cannot eat
11
- // the heap.
12
-
13
- import fs from 'node:fs';
14
- import path from 'node:path';
15
- import os from 'node:os';
16
-
17
- const MAX_FILE_SNAPSHOTS = 60;
18
- const MAX_FILE_BYTES = 2_000_000;
19
-
20
- /**
21
- * The rewind journal for one session. Kept per-session so two windows on the same
22
- * project never share checkpoints.
23
- */
24
- export function createJournal() {
25
- const checkpoints = []; // { seq, at, prompt, messages, files }
26
- const fileSnapshots = []; // newest last, global cap
27
- let seqCounter = 0;
28
-
29
- return {
30
- /** Snapshot the conversation before a user prompt is processed. */
31
- checkpoint(messages, files = []) {
32
- // Checkpoints are full deep clones with NO size cap (only file snapshots are
33
- // capped above) — with pasted images in history, 60 checkpoints of megabytes
34
- // of base64 each is a straight road to heap death. Past the cap the CHECKPOINT
35
- // copy keeps its structure but drops the pixels (live messages are untouched):
36
- // rewinding across an image restores a note, not the pixels. Undo stays usable
37
- // instead of OOMing the session.
38
- const MAX_CHECKPOINT_BYTES = 4_000_000;
39
- let snap;
40
- try {
41
- const raw = JSON.stringify(messages);
42
- snap = raw.length > MAX_CHECKPOINT_BYTES
43
- ? JSON.parse(JSON.stringify(messages, (key, value) =>
44
- (key === 'data' && typeof value === 'string' && value.length > 100_000
45
- ? '[image data omitted from checkpoint]' : value)))
46
- : JSON.parse(raw);
47
- } catch {
48
- snap = [];
49
- }
50
- checkpoints.push({
51
- seq: ++seqCounter,
52
- at: new Date().toISOString(),
53
- prompt: messages.filter((m) => m.role === 'user' && typeof m.content === 'string').at(-1)?.content?.slice(0, 120) || '',
54
- messages: snap,
55
- files,
56
- });
57
- if (checkpoints.length > 60) checkpoints.shift();
58
- },
59
-
60
- /** Remember a file's current bytes before the agent overwrites it. */
61
- snapshotFile(absPath) {
62
- if (fileSnapshots.length >= MAX_FILE_SNAPSHOTS) fileSnapshots.shift();
63
- const seq = ++seqCounter; // monotonic: immune to clock granularity
64
- const takenAt = new Date().toISOString();
65
- try {
66
- const stat = fs.statSync(absPath);
67
- if (!stat.isFile() || stat.size > MAX_FILE_BYTES) return;
68
- fileSnapshots.push({ path: absPath, data: fs.readFileSync(absPath), takenAt, seq });
69
- } catch {
70
- // Unreadable or vanished — restoring means deleting it if it exists now.
71
- fileSnapshots.push({ path: absPath, data: null, takenAt, seq });
72
- }
73
- },
74
-
75
- /**
76
- * Snapshot every file this journal has already seen, plus the session save file.
77
- * Used before run_command: the shell can touch anything, but re-snapshotting
78
- * known files is bounded and covers the session's own blast radius.
79
- */
80
- snapshotTracked(cwd) {
81
- for (const snap of fileSnapshots) {
82
- try { this.snapshotFile(snap.path); } catch { /* already counted */ }
83
- }
84
- try { this.snapshotFile(path.join(cwd, '.jaitus', 'session.json')); } catch { /* fine */ }
85
- },
86
-
87
- /**
88
- * The most recent snapshot of one path, as text.
89
- *
90
- * The journal already holds what a file looked like before the agent touched it,
91
- * which is exactly the "before" side of a diff — so showing what an edit actually
92
- * did costs a lookup rather than a second read of the disk.
93
- */
94
- latestSnapshot(absPath) {
95
- for (let i = fileSnapshots.length - 1; i >= 0; i--) {
96
- if (fileSnapshots[i].path === absPath) {
97
- return fileSnapshots[i].data ? fileSnapshots[i].data.toString('utf8') : '';
98
- }
99
- }
100
- return null;
101
- },
102
-
103
- /** Turn summaries for the rewind dialog, oldest first. */
104
- list() {
105
- return checkpoints.map((cp, i) => ({ index: i, at: cp.at, prompt: cp.prompt, files: cp.files.length }));
106
- },
107
-
108
- /**
109
- * Restore checkpoint `index`: truncate messages to that point and restore every
110
- * file the agent changed after it. Returns what happened, for the transcript.
111
- */
112
- restore(index, messages) {
113
- const cp = checkpoints[index];
114
- if (!cp) return { ok: false, error: 'No such checkpoint.' };
115
- const restored = [];
116
- const deleted = [];
117
- for (const snap of [...fileSnapshots].reverse()) {
118
- if (snap.seq <= cp.seq) continue;
119
- try {
120
- if (snap.data) {
121
- fs.mkdirSync(path.dirname(snap.path), { recursive: true });
122
- fs.writeFileSync(snap.path, snap.data);
123
- restored.push(snap.path);
124
- } else if (fs.existsSync(snap.path)) {
125
- fs.rmSync(snap.path);
126
- deleted.push(snap.path);
127
- }
128
- } catch { /* best effort per file */ }
129
- }
130
- messages.length = 0;
131
- messages.push(...JSON.parse(JSON.stringify(cp.messages)));
132
- return { ok: true, restored, deleted, messageCount: cp.messages.length };
133
- },
134
-
135
- /** Drop all checkpoints (new session, /clear). */
136
- reset() {
137
- checkpoints.length = 0;
138
- fileSnapshots.length = 0;
139
- },
140
- };
141
- }
142
-
143
- /** Human-friendly absolute-path display (short home, forward slashes). */
144
- export function displayPath(p) {
145
- const home = os.homedir();
146
- const s = String(p || '');
147
- return s.startsWith(home) ? `~${s.slice(home.length)}` : s;
148
- }
1
+ // ─── Rewind: go back to any earlier turn ────────────────────────────────────────
2
+ //
3
+ // Esc+Esc rewinds the conversation to a previous point — and restores the files as
4
+ // they were. The mechanism is simple and honest: before each user prompt
5
+ // is processed, snapshot the message history; before any write/edit command the agent
6
+ // makes, snapshot the file it is about to touch. Restoring a checkpoint then means
7
+ // truncating the message array and copying the file bytes back.
8
+ //
9
+ // Snapshots live in memory for the session's life: this is undo, not backup. Files are
10
+ // capped (count and size) so one `run_command` that rewrites a giant file cannot eat
11
+ // the heap.
12
+
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import os from 'node:os';
16
+
17
+ const MAX_FILE_SNAPSHOTS = 60;
18
+ const MAX_FILE_BYTES = 2_000_000;
19
+
20
+ /**
21
+ * The rewind journal for one session. Kept per-session so two windows on the same
22
+ * project never share checkpoints.
23
+ */
24
+ export function createJournal() {
25
+ const checkpoints = []; // { seq, at, prompt, messages, files }
26
+ const fileSnapshots = []; // newest last, global cap
27
+ let seqCounter = 0;
28
+
29
+ return {
30
+ /** Snapshot the conversation before a user prompt is processed. */
31
+ checkpoint(messages, files = []) {
32
+ // Checkpoints are deep clones, and with pasted images in history 60 of them is
33
+ // megabytes of base64 each. Past the cap the CHECKPOINT copy (never the live
34
+ // conversation) replaces each large image with a text note: rewinding across it
35
+ // restores the note, not the pixels. The whole block is replaced, not just its
36
+ // data — an image block whose data is a sentence is rejected by every provider,
37
+ // so a rewind used to make every following request fail.
38
+ const MAX_CHECKPOINT_BYTES = 4_000_000;
39
+ const NOTE = { type: 'text', text: '[An image was here; it is not kept in undo history.]' };
40
+ const lighter = (key, value) => (value && typeof value === 'object' && value.type === 'image'
41
+ && String(value.source?.data || '').length > 100_000 ? NOTE : value);
42
+ let snap;
43
+ try {
44
+ const raw = JSON.stringify(messages);
45
+ snap = JSON.parse(raw.length > MAX_CHECKPOINT_BYTES ? JSON.stringify(messages, lighter) : raw);
46
+ } catch {
47
+ // Too large even to serialise: keep the structure without images rather than
48
+ // an empty checkpoint, which would rewind the conversation to nothing.
49
+ try { snap = JSON.parse(JSON.stringify(messages, (k, v) => (v && typeof v === 'object' && v.type === 'image' ? NOTE : v))); } catch { return; }
50
+ }
51
+ checkpoints.push({
52
+ seq: ++seqCounter,
53
+ at: new Date().toISOString(),
54
+ prompt: messages.filter((m) => m.role === 'user' && typeof m.content === 'string').at(-1)?.content?.slice(0, 120) || '',
55
+ messages: snap,
56
+ files,
57
+ });
58
+ if (checkpoints.length > 60) checkpoints.shift();
59
+ },
60
+
61
+ /** Remember a file's current bytes before the agent overwrites it. */
62
+ snapshotFile(absPath) {
63
+ if (fileSnapshots.length >= MAX_FILE_SNAPSHOTS) fileSnapshots.shift();
64
+ const seq = ++seqCounter; // monotonic: immune to clock granularity
65
+ const takenAt = new Date().toISOString();
66
+ try {
67
+ const stat = fs.statSync(absPath);
68
+ if (!stat.isFile() || stat.size > MAX_FILE_BYTES) return;
69
+ fileSnapshots.push({ path: absPath, data: fs.readFileSync(absPath), takenAt, seq });
70
+ } catch {
71
+ // Unreadable or vanished — restoring means deleting it if it exists now.
72
+ fileSnapshots.push({ path: absPath, data: null, takenAt, seq });
73
+ }
74
+ },
75
+
76
+ /**
77
+ * Snapshot every file this journal has already seen, plus the session save file.
78
+ * Used before run_command: the shell can touch anything, but re-snapshotting
79
+ * known files is bounded and covers the session's own blast radius.
80
+ */
81
+ snapshotTracked(cwd) {
82
+ for (const snap of fileSnapshots) {
83
+ try { this.snapshotFile(snap.path); } catch { /* already counted */ }
84
+ }
85
+ try { this.snapshotFile(path.join(cwd, '.jaitus', 'session.json')); } catch { /* fine */ }
86
+ },
87
+
88
+ /**
89
+ * The most recent snapshot of one path, as text.
90
+ *
91
+ * The journal already holds what a file looked like before the agent touched it,
92
+ * which is exactly the "before" side of a diff — so showing what an edit actually
93
+ * did costs a lookup rather than a second read of the disk.
94
+ */
95
+ latestSnapshot(absPath) {
96
+ for (let i = fileSnapshots.length - 1; i >= 0; i--) {
97
+ if (fileSnapshots[i].path === absPath) {
98
+ return fileSnapshots[i].data ? fileSnapshots[i].data.toString('utf8') : '';
99
+ }
100
+ }
101
+ return null;
102
+ },
103
+
104
+ /** Turn summaries for the rewind dialog, oldest first. */
105
+ list() {
106
+ return checkpoints.map((cp, i) => ({ index: i, at: cp.at, prompt: cp.prompt, files: cp.files.length }));
107
+ },
108
+
109
+ /**
110
+ * Restore checkpoint `index`: truncate messages to that point and restore every
111
+ * file the agent changed after it. Returns what happened, for the transcript.
112
+ */
113
+ restore(index, messages) {
114
+ const cp = checkpoints[index];
115
+ if (!cp) return { ok: false, error: 'No such checkpoint.' };
116
+ const restored = [];
117
+ const deleted = [];
118
+ for (const snap of [...fileSnapshots].reverse()) {
119
+ if (snap.seq <= cp.seq) continue;
120
+ try {
121
+ if (snap.data) {
122
+ fs.mkdirSync(path.dirname(snap.path), { recursive: true });
123
+ fs.writeFileSync(snap.path, snap.data);
124
+ restored.push(snap.path);
125
+ } else if (fs.existsSync(snap.path)) {
126
+ fs.rmSync(snap.path);
127
+ deleted.push(snap.path);
128
+ }
129
+ } catch { /* best effort per file */ }
130
+ }
131
+ messages.length = 0;
132
+ messages.push(...JSON.parse(JSON.stringify(cp.messages)));
133
+ return { ok: true, restored, deleted, messageCount: cp.messages.length };
134
+ },
135
+
136
+ /** Drop all checkpoints (new session, /clear). */
137
+ reset() {
138
+ checkpoints.length = 0;
139
+ fileSnapshots.length = 0;
140
+ },
141
+ };
142
+ }
143
+
144
+ /** Human-friendly absolute-path display (short home, forward slashes). */
145
+ export function displayPath(p) {
146
+ const home = os.homedir();
147
+ const s = String(p || '');
148
+ return s.startsWith(home) ? `~${s.slice(home.length)}` : s;
149
+ }
@@ -0,0 +1,151 @@
1
+ // ─── Service notices in the CLI ─────────────────────────────────────────────────
2
+ //
3
+ // The status page (www.jaitus.com/status) is the record. The CLI only says something
4
+ // when it affects using Jaitus right now, the way flyctl checks its status page:
5
+ //
6
+ // - At start-up, in the background with a short timeout: one line for a major or
7
+ // critical incident on the Jaitus service, maintenance in progress, or maintenance
8
+ // starting within 24 hours. Each notice at most once a day on this machine.
9
+ // - When a request has finally failed (after the usual retries) as unavailable, timed
10
+ // out or down for maintenance: the error says which known problem it is, or when
11
+ // Jaitus will be back.
12
+ //
13
+ // The server decides which notices qualify (GET /api/status/notices), by its own clock.
14
+ // Nothing here ever blocks, and any failure is silent. JAITUS_NO_STATUS_CHECK=1 or CI
15
+ // turns the start-up line off.
16
+
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { HOME, endpoints } from './config.js';
20
+
21
+ const TIMEOUT_MS = 2000;
22
+ const DAY_MS = 24 * 60 * 60 * 1000;
23
+ const SHOWN_FILE = path.join(HOME, 'notice-check.json');
24
+
25
+ /** Whether the start-up line may run at all. */
26
+ export function noticeCheckEnabled(env = process.env) {
27
+ return !(env.JAITUS_NO_STATUS_CHECK || env.CI);
28
+ }
29
+
30
+ /** The public notices payload, or null. Never throws. */
31
+ export async function fetchNotices({ baseUrl = endpoints().baseUrl, fetchImpl = fetch, timeoutMs = TIMEOUT_MS } = {}) {
32
+ const ctrl = new AbortController();
33
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
34
+ try {
35
+ const res = await fetchImpl(`${baseUrl}/api/status/notices`, { signal: ctrl.signal, headers: { accept: 'application/json' } });
36
+ if (!res.ok) return null;
37
+ const body = await res.json();
38
+ return body && typeof body === 'object' && body.data && typeof body.data === 'object' ? body.data : null;
39
+ } catch {
40
+ return null;
41
+ } finally {
42
+ clearTimeout(timer);
43
+ }
44
+ }
45
+
46
+ /** "www.jaitus.com/status", from the configured site. */
47
+ export function statusAddress(siteUrl = endpoints().siteUrl) {
48
+ return `${String(siteUrl).replace(/^https?:\/\//, '').replace(/\/+$/, '')}/status`;
49
+ }
50
+
51
+ // Text from the server is printed into a terminal: no escape codes, no control
52
+ // characters, one short line.
53
+ const clean = (t, max = 100) => {
54
+ const s = String(t ?? '')
55
+ // eslint-disable-next-line no-control-regex
56
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
57
+ // eslint-disable-next-line no-control-regex
58
+ .replace(/[\x00-\x1f\x7f-\x9f]/g, ' ')
59
+ .replace(/\s+/g, ' ')
60
+ .trim();
61
+ return s.length > max ? `${s.slice(0, max - 1)}…` : s;
62
+ };
63
+ const dropStop = (t) => t.replace(/[.!?…]+$/, '');
64
+
65
+ /** The start-up line for one notice from the server's `cli` list. */
66
+ export function lineFor(item, where = statusAddress()) {
67
+ if (!item || typeof item !== 'object') return null;
68
+ const title = dropStop(clean(item.title));
69
+ if (item.kind === 'incident') {
70
+ return `Jaitus is having problems${title ? `: ${title}` : ''}. Updates: ${where}`;
71
+ }
72
+ if (item.kind === 'maintenance' && item.phase === 'in_progress') {
73
+ const until = clean(item.until, 40);
74
+ if (item.overrun && until) return `Jaitus is down for maintenance, running past its planned end (${until}). Updates: ${where}`;
75
+ if (until) return `Jaitus is down for maintenance until ${until}. Updates: ${where}`;
76
+ return `Jaitus is down for maintenance. Updates: ${where}`;
77
+ }
78
+ if (item.kind === 'maintenance' && item.phase === 'upcoming') {
79
+ const when = clean(item.window, 60);
80
+ if (!when) return null;
81
+ return `Planned maintenance ${when}: Jaitus will not answer during it. Details: ${where}`;
82
+ }
83
+ return null;
84
+ }
85
+
86
+ function readShown(file) {
87
+ try {
88
+ const v = JSON.parse(fs.readFileSync(file, 'utf8'));
89
+ return v && typeof v.shown === 'object' && v.shown ? v.shown : {};
90
+ } catch {
91
+ return {};
92
+ }
93
+ }
94
+
95
+ /**
96
+ * The one line to show at start-up, or null. A notice is shown at most once a day per
97
+ * machine; a planned window and the same window in progress are separate notices.
98
+ */
99
+ export async function startupNotice({ env = process.env, now = Date.now(), file = SHOWN_FILE, fetchNotices: get = fetchNotices, where } = {}) {
100
+ if (!noticeCheckEnabled(env)) return null;
101
+ const data = await get().catch(() => null);
102
+ const items = Array.isArray(data?.cli) ? data.cli : [];
103
+ if (!items.length) return null;
104
+ const shown = readShown(file);
105
+ for (const item of items) {
106
+ const key = `${item?.id}:${item?.phase}`;
107
+ if (shown[key] != null && now - Number(shown[key]) < DAY_MS) continue;
108
+ const line = lineFor(item, where);
109
+ if (!line) continue;
110
+ shown[key] = now;
111
+ // Forget anything a week old, so the file never grows.
112
+ for (const k of Object.keys(shown)) if (now - Number(shown[k]) > 7 * DAY_MS) delete shown[k];
113
+ try {
114
+ fs.mkdirSync(path.dirname(file), { recursive: true });
115
+ fs.writeFileSync(file, JSON.stringify({ shown }));
116
+ } catch { /* read-only home: it may show again next start */ }
117
+ return line;
118
+ }
119
+ return null;
120
+ }
121
+
122
+ const EXPLAINED = new Set(['maintenance', 'unavailable', 'timeout']);
123
+
124
+ /**
125
+ * A request that finally failed: add what the status page knows to the error's hint —
126
+ * when Jaitus is back from maintenance, or which known problem this is. Mutates and
127
+ * returns `error`; leaves it alone when there is nothing to add or the check fails.
128
+ */
129
+ export async function explainFailure(error, { baseUrl, fetchNotices: get = fetchNotices, where } = {}) {
130
+ if (!error || !EXPLAINED.has(error.code)) return error;
131
+ const data = await get(baseUrl ? { baseUrl } : undefined).catch(() => null);
132
+ if (!data) return error;
133
+ const at = where || statusAddress();
134
+ const m = data.maintenance;
135
+ if (error.code === 'maintenance' || m?.enabled) {
136
+ const until = clean(m?.until, 40);
137
+ error.title = 'Down for maintenance';
138
+ error.hint = m?.enabled && until
139
+ ? (m.overrun
140
+ ? `It was planned to end at ${until} and is running late. Updates: ${at}`
141
+ : `Jaitus should be back by ${until}. Updates: ${at}`)
142
+ : `Try again shortly. Updates: ${at}`;
143
+ return error;
144
+ }
145
+ const incident = Array.isArray(data.incidents) ? data.incidents[0] : null;
146
+ const title = incident ? dropStop(clean(incident.title)) : '';
147
+ error.hint = title
148
+ ? `Known problem: ${title}. Updates: ${at}`
149
+ : `${error.hint ? `${dropStop(error.hint)}. ` : ''}Service status: ${at}`;
150
+ return error;
151
+ }
package/src/sessions.js CHANGED
@@ -9,6 +9,7 @@ import crypto from 'node:crypto';
9
9
  import { SESSIONS } from './config.js';
10
10
  import { c } from './ui.js';
11
11
  import { unifiedDiff } from './diff.js';
12
+ import { TOOLS } from './agent/tools.js';
12
13
 
13
14
  function safeId(id) {
14
15
  return String(id || '').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 80);
@@ -195,6 +196,13 @@ export function replaySession(tui, messages) {
195
196
  /** The same one-line summary the live loop shows, without importing the tool table. */
196
197
  function summariseCall(call) {
197
198
  const input = call.input && typeof call.input === 'object' ? call.input : {};
199
+ // The same label the row had while the session ran. A second, guessed label here
200
+ // showed a resumed task list as `{"todos":[{"content":…` and a background read as
201
+ // `{"id":"bg1"}`.
202
+ const tool = TOOLS.find((t) => t.name === call.name);
203
+ if (tool?.summarise) {
204
+ try { const s = tool.summarise(input); if (s) return String(s); } catch { /* fall back below */ }
205
+ }
198
206
  if (input.command) return String(input.command);
199
207
  if (input.path) return String(input.path);
200
208
  if (input.pattern) return String(input.pattern);