jaitus 0.3.12 → 0.3.13

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
+ }