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/bin/jaitus.js +86 -73
- package/package.json +2 -2
- package/src/agent/loop.js +990 -980
- package/src/api.js +174 -140
- package/src/errors.js +120 -106
- package/src/index.js +3148 -3142
- package/src/rewind.js +149 -148
- package/src/tui/clipboard.js +172 -161
- package/src/tui-ink.js +2619 -2605
- package/src/tui.js +2560 -2559
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
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
const MAX_CHECKPOINT_BYTES = 4_000_000;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
*
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
fs.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
messages.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
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
|
+
}
|