jaitus 0.3.0
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/LICENSE +32 -0
- package/README.md +248 -0
- package/bin/jaitus.js +60 -0
- package/package.json +46 -0
- package/src/agent/loop.js +548 -0
- package/src/agent/permissions.js +218 -0
- package/src/agent/rules.js +190 -0
- package/src/agent/tools.js +923 -0
- package/src/agent/websearch.js +522 -0
- package/src/api.js +72 -0
- package/src/background.js +85 -0
- package/src/brand.js +232 -0
- package/src/commands/code-review.js +8 -0
- package/src/commands/commit.js +73 -0
- package/src/commands/completion.js +68 -0
- package/src/commands/doctor.js +185 -0
- package/src/commands/explain.js +33 -0
- package/src/commands/init.js +62 -0
- package/src/commands/login.js +201 -0
- package/src/commands/logout.js +25 -0
- package/src/commands/research.js +60 -0
- package/src/commands/review.js +80 -0
- package/src/commands/security-review.js +8 -0
- package/src/commands/simplify.js +8 -0
- package/src/commands/test.js +68 -0
- package/src/commands/update.js +97 -0
- package/src/config.js +95 -0
- package/src/diff.js +129 -0
- package/src/errors.js +101 -0
- package/src/hooks.js +55 -0
- package/src/index.js +2503 -0
- package/src/ledger.js +73 -0
- package/src/logo-data.js +33 -0
- package/src/mcp.js +160 -0
- package/src/open.js +50 -0
- package/src/project.js +0 -0
- package/src/rewind.js +130 -0
- package/src/session.js +207 -0
- package/src/sessions.js +179 -0
- package/src/sixel.js +131 -0
- package/src/skills.js +62 -0
- package/src/term-sim.js +103 -0
- package/src/terminal.js +90 -0
- package/src/tui/ansi.js +287 -0
- package/src/tui/entries.js +366 -0
- package/src/tui/highlight.js +211 -0
- package/src/tui/logo.js +93 -0
- package/src/tui/markdown.js +280 -0
- package/src/tui/mouse.js +125 -0
- package/src/tui/theme.js +201 -0
- package/src/tui/widgets.js +226 -0
- package/src/tui/wordmark.js +135 -0
- package/src/tui-ink.js +1927 -0
- package/src/tui.js +2555 -0
- package/src/ui.js +562 -0
package/src/rewind.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
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.push({
|
|
33
|
+
seq: ++seqCounter,
|
|
34
|
+
at: new Date().toISOString(),
|
|
35
|
+
prompt: messages.filter((m) => m.role === 'user' && typeof m.content === 'string').at(-1)?.content?.slice(0, 120) || '',
|
|
36
|
+
messages: JSON.parse(JSON.stringify(messages)),
|
|
37
|
+
files,
|
|
38
|
+
});
|
|
39
|
+
if (checkpoints.length > 60) checkpoints.shift();
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
/** Remember a file's current bytes before the agent overwrites it. */
|
|
43
|
+
snapshotFile(absPath) {
|
|
44
|
+
if (fileSnapshots.length >= MAX_FILE_SNAPSHOTS) fileSnapshots.shift();
|
|
45
|
+
const seq = ++seqCounter; // monotonic: immune to clock granularity
|
|
46
|
+
const takenAt = new Date().toISOString();
|
|
47
|
+
try {
|
|
48
|
+
const stat = fs.statSync(absPath);
|
|
49
|
+
if (!stat.isFile() || stat.size > MAX_FILE_BYTES) return;
|
|
50
|
+
fileSnapshots.push({ path: absPath, data: fs.readFileSync(absPath), takenAt, seq });
|
|
51
|
+
} catch {
|
|
52
|
+
// Unreadable or vanished — restoring means deleting it if it exists now.
|
|
53
|
+
fileSnapshots.push({ path: absPath, data: null, takenAt, seq });
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Snapshot every file this journal has already seen, plus the session save file.
|
|
59
|
+
* Used before run_command: the shell can touch anything, but re-snapshotting
|
|
60
|
+
* known files is bounded and covers the session's own blast radius.
|
|
61
|
+
*/
|
|
62
|
+
snapshotTracked(cwd) {
|
|
63
|
+
for (const snap of fileSnapshots) {
|
|
64
|
+
try { this.snapshotFile(snap.path); } catch { /* already counted */ }
|
|
65
|
+
}
|
|
66
|
+
try { this.snapshotFile(path.join(cwd, '.jaitus', 'session.json')); } catch { /* fine */ }
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The most recent snapshot of one path, as text.
|
|
71
|
+
*
|
|
72
|
+
* The journal already holds what a file looked like before the agent touched it,
|
|
73
|
+
* which is exactly the "before" side of a diff — so showing what an edit actually
|
|
74
|
+
* did costs a lookup rather than a second read of the disk.
|
|
75
|
+
*/
|
|
76
|
+
latestSnapshot(absPath) {
|
|
77
|
+
for (let i = fileSnapshots.length - 1; i >= 0; i--) {
|
|
78
|
+
if (fileSnapshots[i].path === absPath) {
|
|
79
|
+
return fileSnapshots[i].data ? fileSnapshots[i].data.toString('utf8') : '';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
/** Turn summaries for the rewind dialog, oldest first. */
|
|
86
|
+
list() {
|
|
87
|
+
return checkpoints.map((cp, i) => ({ index: i, at: cp.at, prompt: cp.prompt, files: cp.files.length }));
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Restore checkpoint `index`: truncate messages to that point and restore every
|
|
92
|
+
* file the agent changed after it. Returns what happened, for the transcript.
|
|
93
|
+
*/
|
|
94
|
+
restore(index, messages) {
|
|
95
|
+
const cp = checkpoints[index];
|
|
96
|
+
if (!cp) return { ok: false, error: 'No such checkpoint.' };
|
|
97
|
+
const restored = [];
|
|
98
|
+
const deleted = [];
|
|
99
|
+
for (const snap of [...fileSnapshots].reverse()) {
|
|
100
|
+
if (snap.seq <= cp.seq) continue;
|
|
101
|
+
try {
|
|
102
|
+
if (snap.data) {
|
|
103
|
+
fs.mkdirSync(path.dirname(snap.path), { recursive: true });
|
|
104
|
+
fs.writeFileSync(snap.path, snap.data);
|
|
105
|
+
restored.push(snap.path);
|
|
106
|
+
} else if (fs.existsSync(snap.path)) {
|
|
107
|
+
fs.rmSync(snap.path);
|
|
108
|
+
deleted.push(snap.path);
|
|
109
|
+
}
|
|
110
|
+
} catch { /* best effort per file */ }
|
|
111
|
+
}
|
|
112
|
+
messages.length = 0;
|
|
113
|
+
messages.push(...JSON.parse(JSON.stringify(cp.messages)));
|
|
114
|
+
return { ok: true, restored, deleted, messageCount: cp.messages.length };
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
/** Drop all checkpoints (new session, /clear). */
|
|
118
|
+
reset() {
|
|
119
|
+
checkpoints.length = 0;
|
|
120
|
+
fileSnapshots.length = 0;
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Human-friendly absolute-path display (short home, forward slashes). */
|
|
126
|
+
export function displayPath(p) {
|
|
127
|
+
const home = os.homedir();
|
|
128
|
+
const s = String(p || '');
|
|
129
|
+
return s.startsWith(home) ? `~${s.slice(home.length)}` : s;
|
|
130
|
+
}
|
package/src/session.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// ─── This device's Jaitus session ───────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// `jaitus login` leaves two tokens in ~/.jaitus/credentials.json: an ACCESS token good
|
|
4
|
+
// for an hour, and a REFRESH token that buys the next pair and is retired when it does.
|
|
5
|
+
// This file is the only code that touches them. Everything else asks for "a token that
|
|
6
|
+
// works right now" and gets one — renewed here, silently, when its hour is up.
|
|
7
|
+
//
|
|
8
|
+
// Renewal is serialised across processes with a lock file. Two terminals running
|
|
9
|
+
// `jaitus` share one credentials file; if both renewed the same pair at once, the server
|
|
10
|
+
// would accept one and see the other presenting a retired token. It treats that as a
|
|
11
|
+
// race for thirty seconds (and says so), but the lock means it almost never happens: the
|
|
12
|
+
// second process waits, re-reads the file, and finds the pair the first one just saved.
|
|
13
|
+
//
|
|
14
|
+
// JAITUS_TOKEN, when set, is used as-is and never renewed — for the test harness and for
|
|
15
|
+
// pointing one command at a local backend.
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import crypto from 'node:crypto';
|
|
21
|
+
import { HOME, loadCredentials, saveCredentials, clearCredentials, endpoints } from './config.js';
|
|
22
|
+
import { JaitusError, fromGatewayBody, fromNetwork } from './errors.js';
|
|
23
|
+
|
|
24
|
+
const DEVICE_FILE = path.join(HOME, 'device.json');
|
|
25
|
+
const LOCK_FILE = path.join(HOME, 'refresh.lock');
|
|
26
|
+
// Renew a little early: a token that expires between the check and the request is a
|
|
27
|
+
// needless 401 and a retry.
|
|
28
|
+
const EARLY_MS = 60 * 1000;
|
|
29
|
+
const LOCK_STALE_MS = 15 * 1000;
|
|
30
|
+
const LOCK_WAIT_MS = 10 * 1000;
|
|
31
|
+
|
|
32
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A random id naming this machine to the server, made once and kept. It is what lets a
|
|
36
|
+
* second `jaitus login` on the same machine REPLACE the first session instead of adding
|
|
37
|
+
* another. Not a secret: it grants nothing on its own.
|
|
38
|
+
*/
|
|
39
|
+
export function deviceId() {
|
|
40
|
+
try {
|
|
41
|
+
const saved = JSON.parse(fs.readFileSync(DEVICE_FILE, 'utf8'));
|
|
42
|
+
if (typeof saved?.id === 'string' && /^[A-Za-z0-9_-]{8,80}$/.test(saved.id)) return saved.id;
|
|
43
|
+
} catch { /* first run */ }
|
|
44
|
+
const id = crypto.randomBytes(18).toString('base64url');
|
|
45
|
+
try {
|
|
46
|
+
fs.mkdirSync(HOME, { recursive: true, mode: 0o700 });
|
|
47
|
+
fs.writeFileSync(DEVICE_FILE, JSON.stringify({ id, createdAt: new Date().toISOString() }, null, 2), { mode: 0o600 });
|
|
48
|
+
} catch { /* an unwritable home still signs in; it just cannot dedupe next time */ }
|
|
49
|
+
return id;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function deviceName() {
|
|
53
|
+
return os.hostname().slice(0, 60);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Whether there is anything to sign requests with. */
|
|
57
|
+
export function signedIn() {
|
|
58
|
+
if (process.env.JAITUS_TOKEN) return true;
|
|
59
|
+
const c = loadCredentials();
|
|
60
|
+
return Boolean(c?.refreshToken && c?.accessToken);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function fresh(creds) {
|
|
64
|
+
const at = Date.parse(creds?.accessExpiresAt || '');
|
|
65
|
+
return Boolean(creds?.accessToken) && Number.isFinite(at) && at - EARLY_MS > Date.now();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function withLock(fn) {
|
|
69
|
+
fs.mkdirSync(HOME, { recursive: true, mode: 0o700 });
|
|
70
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
71
|
+
let fd = null;
|
|
72
|
+
while (fd === null) {
|
|
73
|
+
try {
|
|
74
|
+
fd = fs.openSync(LOCK_FILE, 'wx');
|
|
75
|
+
} catch (e) {
|
|
76
|
+
if (e.code !== 'EEXIST') throw e;
|
|
77
|
+
// A process that died holding the lock must not wedge every later one.
|
|
78
|
+
try {
|
|
79
|
+
if (Date.now() - fs.statSync(LOCK_FILE).mtimeMs > LOCK_STALE_MS) { fs.rmSync(LOCK_FILE, { force: true }); continue; }
|
|
80
|
+
} catch { continue; }
|
|
81
|
+
if (Date.now() > deadline) throw new JaitusError({ code: 'busy', message: 'Another Jaitus window is renewing your session. Try again.' });
|
|
82
|
+
await sleep(100);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
return await fn();
|
|
87
|
+
} finally {
|
|
88
|
+
try { fs.closeSync(fd); } catch { /* ignore */ }
|
|
89
|
+
try { fs.rmSync(LOCK_FILE, { force: true }); } catch { /* ignore */ }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Renew the pair. Returns the new access token, or throws a JaitusError. `rejected` is
|
|
95
|
+
* the access token the server just refused, if any: if the file already holds a
|
|
96
|
+
* different, fresh one, another process renewed first and there is nothing to do.
|
|
97
|
+
*/
|
|
98
|
+
async function renew({ rejected = null } = {}) {
|
|
99
|
+
return withLock(async () => {
|
|
100
|
+
const creds = loadCredentials();
|
|
101
|
+
if (!creds?.refreshToken) throw new JaitusError({ code: 'auth_required' });
|
|
102
|
+
if (fresh(creds) && creds.accessToken !== rejected) return creds.accessToken;
|
|
103
|
+
|
|
104
|
+
const { baseUrl } = endpoints();
|
|
105
|
+
let res;
|
|
106
|
+
try {
|
|
107
|
+
res = await fetch(`${baseUrl}/api/auth/cli/refresh`, {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
headers: { 'Content-Type': 'application/json', 'user-agent': 'jaitus-cli' },
|
|
110
|
+
body: JSON.stringify({ refreshToken: creds.refreshToken }),
|
|
111
|
+
});
|
|
112
|
+
} catch (cause) {
|
|
113
|
+
throw fromNetwork(cause);
|
|
114
|
+
}
|
|
115
|
+
const body = await res.json().catch(() => ({}));
|
|
116
|
+
|
|
117
|
+
if (res.ok && body?.data?.accessToken && body?.data?.refreshToken) {
|
|
118
|
+
saveCredentials({
|
|
119
|
+
...creds,
|
|
120
|
+
accessToken: body.data.accessToken,
|
|
121
|
+
refreshToken: body.data.refreshToken,
|
|
122
|
+
accessExpiresAt: body.data.accessExpiresAt,
|
|
123
|
+
});
|
|
124
|
+
return body.data.accessToken;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const err = fromGatewayBody(res.status, body);
|
|
128
|
+
if (err.code === 'refresh_race' || res.status === 409) {
|
|
129
|
+
// Someone renewed this pair a moment ago without our lock (an older CLI, or a
|
|
130
|
+
// second machine sharing a home directory). Their pair is in the file now.
|
|
131
|
+
await sleep(250);
|
|
132
|
+
const after = loadCredentials();
|
|
133
|
+
if (after?.refreshToken && after.refreshToken !== creds.refreshToken) return after.accessToken;
|
|
134
|
+
}
|
|
135
|
+
if (res.status === 401) {
|
|
136
|
+
// The server has ended this session — signed out from the website, replaced by a
|
|
137
|
+
// newer login on this machine, or expired. The tokens are dead; keeping them
|
|
138
|
+
// would only make every command fail the same way.
|
|
139
|
+
clearCredentials();
|
|
140
|
+
throw new JaitusError({ code: 'session_expired', status: 401 });
|
|
141
|
+
}
|
|
142
|
+
throw err;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* An access token that works right now, renewing it if its hour is (nearly) up.
|
|
148
|
+
* Pass `rejected` when the server just refused a token, to force a renewal.
|
|
149
|
+
*/
|
|
150
|
+
export async function accessToken({ rejected = null } = {}) {
|
|
151
|
+
if (process.env.JAITUS_TOKEN) return process.env.JAITUS_TOKEN.trim();
|
|
152
|
+
const creds = loadCredentials();
|
|
153
|
+
if (!creds?.refreshToken) throw new JaitusError({ code: 'auth_required' });
|
|
154
|
+
if (!rejected && fresh(creds)) return creds.accessToken;
|
|
155
|
+
// Another process may already have renewed: the file holds a different, fresh token.
|
|
156
|
+
if (rejected && fresh(creds) && creds.accessToken !== rejected) return creds.accessToken;
|
|
157
|
+
return renew({ rejected });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Run `send(token)`; if the server says the token is spent (its hour is up, or another
|
|
162
|
+
* process renewed the pair under us), renew once and send once more. Never loops.
|
|
163
|
+
*/
|
|
164
|
+
export async function withAuth(send) {
|
|
165
|
+
if (process.env.JAITUS_TOKEN) return send(process.env.JAITUS_TOKEN.trim());
|
|
166
|
+
const token = await accessToken();
|
|
167
|
+
const res = await send(token);
|
|
168
|
+
if (res.status !== 401) return res;
|
|
169
|
+
|
|
170
|
+
let code = '';
|
|
171
|
+
try { code = (await res.clone().json())?.error?.code || ''; } catch { /* not ours */ }
|
|
172
|
+
if (code !== 'jaitus_token_expired' && code !== 'jaitus_session_expired') return res;
|
|
173
|
+
|
|
174
|
+
let next;
|
|
175
|
+
try {
|
|
176
|
+
next = await accessToken({ rejected: token });
|
|
177
|
+
} catch (e) {
|
|
178
|
+
if (e?.code === 'session_expired' || e?.code === 'auth_required') return res;
|
|
179
|
+
throw e;
|
|
180
|
+
}
|
|
181
|
+
return send(next);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* End this device's session on the server, then forget it here. The server call is best
|
|
186
|
+
* effort — offline, the local tokens are still removed and the session lapses on its own.
|
|
187
|
+
*/
|
|
188
|
+
export async function signOut() {
|
|
189
|
+
const creds = loadCredentials();
|
|
190
|
+
if (!creds?.refreshToken && !creds?.accessToken) return { hadSession: false, server: false };
|
|
191
|
+
const { baseUrl } = endpoints();
|
|
192
|
+
let server = false;
|
|
193
|
+
try {
|
|
194
|
+
const res = await fetch(`${baseUrl}/api/auth/cli/logout`, {
|
|
195
|
+
method: 'POST',
|
|
196
|
+
headers: {
|
|
197
|
+
'Content-Type': 'application/json',
|
|
198
|
+
'user-agent': 'jaitus-cli',
|
|
199
|
+
...(fresh(creds) ? { authorization: `Bearer ${creds.accessToken}` } : {}),
|
|
200
|
+
},
|
|
201
|
+
body: JSON.stringify({ refreshToken: creds.refreshToken }),
|
|
202
|
+
});
|
|
203
|
+
server = res.ok;
|
|
204
|
+
} catch { /* offline: the local copy still goes */ }
|
|
205
|
+
clearCredentials();
|
|
206
|
+
return { hadSession: true, server };
|
|
207
|
+
}
|
package/src/sessions.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// ─── Local conversation sessions ───────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// A terminal session is durable: a closed terminal does not erase the work. Sessions
|
|
4
|
+
// live in a small, inspectable JSON store under ~/.jaitus/sessions.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
import { SESSIONS } from './config.js';
|
|
10
|
+
import { c } from './ui.js';
|
|
11
|
+
|
|
12
|
+
function safeId(id) {
|
|
13
|
+
return String(id || '').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 80);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function fileFor(id) {
|
|
17
|
+
const clean = safeId(id);
|
|
18
|
+
if (!clean) return null;
|
|
19
|
+
return path.join(SESSIONS, `${clean}.json`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function newSession({ cwd, model, id = null } = {}) {
|
|
23
|
+
const sessionId = safeId(id) || crypto.randomUUID();
|
|
24
|
+
const now = new Date().toISOString();
|
|
25
|
+
return {
|
|
26
|
+
id: sessionId,
|
|
27
|
+
cwd: path.resolve(cwd || process.cwd()),
|
|
28
|
+
model: model || null,
|
|
29
|
+
createdAt: now,
|
|
30
|
+
updatedAt: now,
|
|
31
|
+
title: '',
|
|
32
|
+
messages: [],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function saveSession(session) {
|
|
37
|
+
if (!session?.id) return;
|
|
38
|
+
fs.mkdirSync(SESSIONS, { recursive: true, mode: 0o700 });
|
|
39
|
+
const file = fileFor(session.id);
|
|
40
|
+
if (!file) return;
|
|
41
|
+
session.updatedAt = new Date().toISOString();
|
|
42
|
+
const snapshot = { ...session };
|
|
43
|
+
const temp = `${file}.tmp`;
|
|
44
|
+
fs.writeFileSync(temp, JSON.stringify(snapshot, null, 2), { mode: 0o600 });
|
|
45
|
+
fs.renameSync(temp, file);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function loadSession(id) {
|
|
49
|
+
const file = fileFor(id);
|
|
50
|
+
if (!file) return null;
|
|
51
|
+
try {
|
|
52
|
+
const session = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
53
|
+
if (!session?.id || !Array.isArray(session.messages)) return null;
|
|
54
|
+
return session;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function listSessions({ cwd = null } = {}) {
|
|
61
|
+
try {
|
|
62
|
+
return fs.readdirSync(SESSIONS)
|
|
63
|
+
.filter((name) => name.endsWith('.json'))
|
|
64
|
+
.map((name) => loadSession(name.slice(0, -5)))
|
|
65
|
+
.filter(Boolean)
|
|
66
|
+
.filter((session) => !cwd || path.resolve(session.cwd) === path.resolve(cwd))
|
|
67
|
+
.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
|
|
68
|
+
} catch {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function latestSession({ cwd = null } = {}) {
|
|
74
|
+
return listSessions({ cwd })[0] || null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Render a saved conversation back onto the screen: resuming must put the
|
|
79
|
+
* user into the same old session visibly, not just swap the model's memory.
|
|
80
|
+
* Tool calls replay as one dim summary row each; images ride silently.
|
|
81
|
+
*/
|
|
82
|
+
/**
|
|
83
|
+
* Put a saved conversation back on screen.
|
|
84
|
+
*
|
|
85
|
+
* Through the STRUCTURED api, not `print`. It used to flatten every turn into a
|
|
86
|
+
* pre-formatted ANSI string: a resumed session lost all its markdown, its code blocks
|
|
87
|
+
* became run-on prose, its newlines were replaced with spaces, and every tool call
|
|
88
|
+
* collapsed into one dim line of JSON. The conversation was all there in the file and
|
|
89
|
+
* none of it was legible — which is what "resume doesn't load the chat properly" is.
|
|
90
|
+
*
|
|
91
|
+
* Tool calls are replayed with the outcome recorded against them, so a call that failed
|
|
92
|
+
* still reads as failed rather than as a tick. The result text lives in the NEXT
|
|
93
|
+
* message's tool_result blocks, so those are indexed first and matched by id.
|
|
94
|
+
*/
|
|
95
|
+
export function replaySession(tui, messages) {
|
|
96
|
+
if (!tui || !Array.isArray(messages)) return 0;
|
|
97
|
+
tui.clearTranscript();
|
|
98
|
+
let shown = 0;
|
|
99
|
+
|
|
100
|
+
// tool_use_id → its result, from wherever it lands later in the conversation.
|
|
101
|
+
const results = new Map();
|
|
102
|
+
for (const m of messages) {
|
|
103
|
+
if (!Array.isArray(m?.content)) continue;
|
|
104
|
+
for (const b of m.content) {
|
|
105
|
+
if (b?.type === 'tool_result' && b.tool_use_id) {
|
|
106
|
+
results.set(b.tool_use_id, { text: String(b.content ?? ''), failed: !!b.is_error });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const structured = typeof tui.user === 'function' && typeof tui.assistant === 'function';
|
|
112
|
+
|
|
113
|
+
for (const m of messages) {
|
|
114
|
+
if (m.role === 'user') {
|
|
115
|
+
// A user message that is only tool results is the loop's bookkeeping, not
|
|
116
|
+
// something the user said; replaying it as a turn would invent one.
|
|
117
|
+
const text = typeof m.content === 'string'
|
|
118
|
+
? m.content
|
|
119
|
+
: (Array.isArray(m.content) ? m.content : [])
|
|
120
|
+
.filter((b) => b?.type === 'text' && b.text)
|
|
121
|
+
.map((b) => b.text)
|
|
122
|
+
.join('\n\n');
|
|
123
|
+
if (!text.trim()) continue;
|
|
124
|
+
if (structured) tui.user(text); else tui.print(`\n${c.bold(c.brand('›'))} ${c.bold(text.replace(/\n/g, ' '))}`, 'user');
|
|
125
|
+
shown += 1;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (m.role !== 'assistant') continue;
|
|
130
|
+
const blocks = Array.isArray(m.content) ? m.content : [];
|
|
131
|
+
const text = blocks.filter((b) => b?.type === 'text' && b.text).map((b) => b.text).join('\n\n');
|
|
132
|
+
if (text.trim()) {
|
|
133
|
+
if (structured) tui.assistant(text); else tui.print(`${c.brand('◆')} ${text}`, 'assistant');
|
|
134
|
+
shown += 1;
|
|
135
|
+
}
|
|
136
|
+
for (const call of blocks.filter((b) => b?.type === 'tool_use')) {
|
|
137
|
+
const outcome = results.get(call.id);
|
|
138
|
+
const label = summariseCall(call);
|
|
139
|
+
if (structured && tui.tool) {
|
|
140
|
+
tui.tool({
|
|
141
|
+
name: call.name,
|
|
142
|
+
label,
|
|
143
|
+
status: outcome ? (outcome.failed ? 'failed' : 'ok') : 'ok',
|
|
144
|
+
file: call.input?.path,
|
|
145
|
+
error: outcome?.failed ? outcome.text.split('\n')[0].slice(0, 200) : '',
|
|
146
|
+
});
|
|
147
|
+
} else {
|
|
148
|
+
tui.print(` ${c.accent('✓')} ${c.dim(`${call.name} ${label}`.slice(0, 100))}`, 'tool');
|
|
149
|
+
}
|
|
150
|
+
shown += 1;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return shown;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The same one-line summary the live loop shows, without importing the tool table. */
|
|
157
|
+
function summariseCall(call) {
|
|
158
|
+
const input = call.input && typeof call.input === 'object' ? call.input : {};
|
|
159
|
+
if (input.command) return String(input.command);
|
|
160
|
+
if (input.path) return String(input.path);
|
|
161
|
+
if (input.pattern) return String(input.pattern);
|
|
162
|
+
if (input.url) return String(input.url);
|
|
163
|
+
if (input.query) return String(input.query);
|
|
164
|
+
if (input.description) return String(input.description);
|
|
165
|
+
const json = JSON.stringify(input);
|
|
166
|
+
return json === '{}' ? call.name : json.replace(/\s+/g, ' ').slice(0, 90);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function sessionSummary(session) {
|
|
170
|
+
const firstPrompt = session.messages?.find((m) => m.role === 'user' && typeof m.content === 'string')?.content;
|
|
171
|
+
return {
|
|
172
|
+
id: session.id,
|
|
173
|
+
title: session.title || firstPrompt?.replace(/\s+/g, ' ').slice(0, 80) || 'Untitled session',
|
|
174
|
+
cwd: session.cwd,
|
|
175
|
+
model: session.model,
|
|
176
|
+
messages: session.messages?.length || 0,
|
|
177
|
+
updatedAt: session.updatedAt,
|
|
178
|
+
};
|
|
179
|
+
}
|
package/src/sixel.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// ─── The logo as a real image: sixel ────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Sixel (DEC's 1980s graphics protocol) paints real pixels: the terminal allocates
|
|
4
|
+
// colours into registers (`#<n>;2;<r>;<g>;<b>`, components 0-100), then six pixel-rows
|
|
5
|
+
// at a time are emitted as one character each (codepoint 63 + 6 bit-flags, bit 0 = the
|
|
6
|
+
// band's top row). Windows Terminal ≥ 1.22 renders the result inline; xterm, WezTerm,
|
|
7
|
+
// foot and rio do too.
|
|
8
|
+
//
|
|
9
|
+
// Encoded from the checked-in pixel data rather than from a PNG at run time: zero
|
|
10
|
+
// dependencies and one data format. The badge is a smooth gradient, so raw pixels
|
|
11
|
+
// would need more colour registers than terminals provide; every channel is therefore
|
|
12
|
+
// quantised (8/255 steps, coarsening if a terminal's 256-register budget would
|
|
13
|
+
// overflow) before a register is spent on it.
|
|
14
|
+
|
|
15
|
+
import { pixels, SIZE as MARK } from './logo-data.js';
|
|
16
|
+
|
|
17
|
+
const MAX_REGISTERS = 250; // stay clear of the 256 every sixel terminal provides
|
|
18
|
+
|
|
19
|
+
/** The 0-255 channel values this encoder is willing to spend a register on. */
|
|
20
|
+
const quantise = (v, step) => Math.min(255, Math.round(v / step) * step);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Encode the mark as a sixel sequence.
|
|
24
|
+
*
|
|
25
|
+
* @param {{maxWidth?: number, maxRows?: number}} opts
|
|
26
|
+
* device-pixel caps; the image is integer-downscaled if it exceeds them. The mark's
|
|
27
|
+
* native 125px fits any window, so the caps only matter for future bigger sources.
|
|
28
|
+
* @returns {string} the complete escape sequence, ready for stdout
|
|
29
|
+
*/
|
|
30
|
+
export function markSixel({ maxWidth = 480, maxRows = 240 } = {}) {
|
|
31
|
+
const src = pixels();
|
|
32
|
+
const W = MARK.width;
|
|
33
|
+
const H = MARK.height;
|
|
34
|
+
|
|
35
|
+
let scale = 1;
|
|
36
|
+
while (W / scale > maxWidth || H / scale > maxRows) scale++;
|
|
37
|
+
|
|
38
|
+
const cw = Math.floor(W / scale);
|
|
39
|
+
const chh = Math.floor(H / scale);
|
|
40
|
+
|
|
41
|
+
// Register assignment: quantise at 8, coarsen until the palette fits the budget.
|
|
42
|
+
// One flat pass per step is cheap (15,625 pixels worst case) and keeps the code
|
|
43
|
+
// honest — no cleverness to get wrong.
|
|
44
|
+
let reg = null;
|
|
45
|
+
for (const step of [8, 16, 32, 64]) {
|
|
46
|
+
const map = new Map();
|
|
47
|
+
const assigned = new Uint16Array(cw * chh).fill(0xffff);
|
|
48
|
+
let next = 0;
|
|
49
|
+
for (let y = 0; y < chh; y++) {
|
|
50
|
+
for (let x = 0; x < cw; x++) {
|
|
51
|
+
// Box over the scale×scale source block; opaque wins if any source pixel is.
|
|
52
|
+
let r = 0; let g = 0; let b = 0; let n = 0;
|
|
53
|
+
for (let dy = 0; dy < scale; dy++) {
|
|
54
|
+
for (let dx = 0; dx < scale; dx++) {
|
|
55
|
+
const s = (y * scale + dy) * W + (x * scale + dx);
|
|
56
|
+
if (!src.opaque[s]) continue;
|
|
57
|
+
r += src.rgb[s * 3]; g += src.rgb[s * 3 + 1]; b += src.rgb[s * 3 + 2]; n++;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (!n) continue;
|
|
61
|
+
r = quantise(r / n, step); g = quantise(g / n, step); b = quantise(b / n, step);
|
|
62
|
+
const key = (r << 16) | (g << 8) | b;
|
|
63
|
+
let idx = map.get(key);
|
|
64
|
+
if (idx === undefined) {
|
|
65
|
+
idx = next++;
|
|
66
|
+
map.set(key, idx);
|
|
67
|
+
}
|
|
68
|
+
assigned[y * cw + x] = idx;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (next <= MAX_REGISTERS || step === 64) {
|
|
72
|
+
reg = { map, assigned, count: next };
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!reg || !reg.count) return '';
|
|
77
|
+
|
|
78
|
+
// Header: sixel intro, 1:1 raster aspect, full extent.
|
|
79
|
+
const out = ['\x1bPq', `"1;1;${cw};${chh}`];
|
|
80
|
+
for (const [key, idx] of reg.map) {
|
|
81
|
+
const r = (key >> 16) & 0xff; const g = (key >> 8) & 0xff; const b = key & 0xff;
|
|
82
|
+
// Sixel components are 0-100.
|
|
83
|
+
out.push(`#${idx};2;${Math.round(r * 100 / 255)};${Math.round(g * 100 / 255)};${Math.round(b * 100 / 255)}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Bands of six rows. Each column of the band belongs to exactly one register (a
|
|
87
|
+
// pixel has one colour) or none (transparent). Walk the band column by column and
|
|
88
|
+
// emit runs of identical (register, mask): `#idx` switches the pen, `!N?` skips
|
|
89
|
+
// transparent columns — a pen switch does NOT move the cursor, so gaps must be
|
|
90
|
+
// skipped explicitly or every later colour paints shifted left.
|
|
91
|
+
const bands = Math.ceil(chh / 6);
|
|
92
|
+
for (let band = 0; band < bands; band++) {
|
|
93
|
+
const rows = Math.min(6, chh - band * 6);
|
|
94
|
+
const colReg = new Int16Array(cw).fill(-1);
|
|
95
|
+
const colMask = new Uint8Array(cw);
|
|
96
|
+
for (let y = 0; y < rows; y++) {
|
|
97
|
+
const row = band * 6 + y;
|
|
98
|
+
for (let x = 0; x < cw; x++) {
|
|
99
|
+
const idx = reg.assigned[row * cw + x];
|
|
100
|
+
if (idx === 0xffff) continue;
|
|
101
|
+
colReg[x] = idx;
|
|
102
|
+
colMask[x] |= 1 << y;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
out.push('$');
|
|
106
|
+
let x = 0;
|
|
107
|
+
let pen = -1;
|
|
108
|
+
while (x < cw) {
|
|
109
|
+
if (colReg[x] === -1) {
|
|
110
|
+
let gap = 1;
|
|
111
|
+
while (x + gap < cw && colReg[x + gap] === -1) gap++;
|
|
112
|
+
out.push(gap > 3 ? `!${gap}?` : '?'.repeat(gap));
|
|
113
|
+
x += gap;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (colReg[x] !== pen) {
|
|
117
|
+
pen = colReg[x];
|
|
118
|
+
out.push(`#${pen}`);
|
|
119
|
+
}
|
|
120
|
+
let run = 1;
|
|
121
|
+
while (x + run < cw && colReg[x + run] === pen && colMask[x + run] === colMask[x]) run++;
|
|
122
|
+
const ch6 = String.fromCharCode(63 + colMask[x]);
|
|
123
|
+
out.push(run > 3 ? `!${run}${ch6}` : ch6.repeat(run));
|
|
124
|
+
x += run;
|
|
125
|
+
}
|
|
126
|
+
if (band < bands - 1) out.push('-');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
out.push('\x1b\\');
|
|
130
|
+
return out.join('');
|
|
131
|
+
}
|