chels-code 0.0.0-stage → 0.5.1
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/README.md +123 -2
- package/dist/src/cli-options.js +30 -0
- package/dist/src/cli.js +178 -0
- package/dist/src/engine.js +469 -0
- package/dist/src/providers.js +250 -0
- package/dist/src/safety.js +48 -0
- package/dist/src/session.js +188 -0
- package/dist/src/terminal-title.js +41 -0
- package/dist/src/types.js +4 -0
- package/dist/src/ui/App.js +259 -0
- package/dist/src/ui/CommandPalette.js +76 -0
- package/dist/src/ui/ConfirmPanel.js +36 -0
- package/dist/src/ui/Spinner.js +21 -0
- package/dist/src/ui/TextArea.js +243 -0
- package/dist/src/ui/Transcript.js +67 -0
- package/dist/src/ui/theme.js +130 -0
- package/dist/src/ui/useTerminalSize.js +22 -0
- package/dist/src/version.js +1 -0
- package/dist/src/workspace-tools.js +405 -0
- package/dist/test/cli-options.test.js +22 -0
- package/dist/test/intent.test.js +48 -0
- package/dist/test/providers.test.js +104 -0
- package/dist/test/resume.test.js +96 -0
- package/dist/test/safety.test.js +44 -0
- package/dist/test/session.test.js +83 -0
- package/dist/test/title.test.js +73 -0
- package/dist/test/tools.test.js +168 -0
- package/dist/test/ui.test.js +370 -0
- package/package.json +30 -4
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
export const PROVIDER_IDS = ["openrouter", "nvidia", "groq"];
|
|
2
|
+
export const PROVIDERS = {
|
|
3
|
+
openrouter: {
|
|
4
|
+
label: "OpenRouter",
|
|
5
|
+
endpoint: "https://openrouter.ai/api/v1/chat/completions",
|
|
6
|
+
modelsEndpoint: "https://openrouter.ai/api/v1/models",
|
|
7
|
+
keyVariable: "OPENROUTER_API_KEY",
|
|
8
|
+
},
|
|
9
|
+
nvidia: {
|
|
10
|
+
label: "NVIDIA",
|
|
11
|
+
endpoint: "https://integrate.api.nvidia.com/v1/chat/completions",
|
|
12
|
+
modelsEndpoint: "https://integrate.api.nvidia.com/v1/models",
|
|
13
|
+
keyVariable: "NVIDIA_API_KEY",
|
|
14
|
+
},
|
|
15
|
+
groq: {
|
|
16
|
+
label: "Groq",
|
|
17
|
+
endpoint: "https://api.groq.com/openai/v1/chat/completions",
|
|
18
|
+
modelsEndpoint: "https://api.groq.com/openai/v1/models",
|
|
19
|
+
keyVariable: "GROQ_API_KEY",
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
const PREFERRED_MODELS = {
|
|
23
|
+
openrouter: [
|
|
24
|
+
"qwen/qwen3-coder:free",
|
|
25
|
+
"deepseek/deepseek-chat-v3-0324:free",
|
|
26
|
+
"meta-llama/llama-3.3-70b-instruct:free",
|
|
27
|
+
],
|
|
28
|
+
nvidia: [
|
|
29
|
+
"meta/llama-3.3-70b-instruct",
|
|
30
|
+
"qwen/qwen2.5-coder-32b-instruct",
|
|
31
|
+
],
|
|
32
|
+
groq: [
|
|
33
|
+
"llama-3.3-70b-versatile",
|
|
34
|
+
"qwen/qwen3-32b",
|
|
35
|
+
"deepseek-r1-distill-llama-70b",
|
|
36
|
+
],
|
|
37
|
+
};
|
|
38
|
+
async function providerFetch(url, provider, apiKey, options = {}) {
|
|
39
|
+
const headers = new Headers(options.headers);
|
|
40
|
+
headers.set("Authorization", `Bearer ${apiKey}`);
|
|
41
|
+
const response = await fetch(url, {
|
|
42
|
+
...options,
|
|
43
|
+
headers,
|
|
44
|
+
signal: AbortSignal.timeout(120_000),
|
|
45
|
+
});
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
const body = await response.text();
|
|
48
|
+
let detail = body;
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(body);
|
|
51
|
+
detail = parsed.error?.message ?? parsed.message ?? body;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Keep the provider's plain-text error response.
|
|
55
|
+
}
|
|
56
|
+
throw new Error(`${PROVIDERS[provider].label} API returned HTTP ${response.status}: ${detail}`);
|
|
57
|
+
}
|
|
58
|
+
return response;
|
|
59
|
+
}
|
|
60
|
+
function isExplicitlyFree(model) {
|
|
61
|
+
if (typeof model.id === "string" && model.id.endsWith(":free"))
|
|
62
|
+
return true;
|
|
63
|
+
const asPrice = (value) => (typeof value === "number" || (typeof value === "string" && value.trim() !== "")
|
|
64
|
+
? Number(value)
|
|
65
|
+
: Number.NaN);
|
|
66
|
+
const prompt = asPrice(model.pricing?.prompt);
|
|
67
|
+
const completion = asPrice(model.pricing?.completion);
|
|
68
|
+
return Number.isFinite(prompt) && Number.isFinite(completion) && prompt === 0 && completion === 0;
|
|
69
|
+
}
|
|
70
|
+
export async function discoverModels(provider, apiKey) {
|
|
71
|
+
const response = await providerFetch(PROVIDERS[provider].modelsEndpoint, provider, apiKey);
|
|
72
|
+
const data = await response.json();
|
|
73
|
+
if (typeof data !== "object"
|
|
74
|
+
|| data === null
|
|
75
|
+
|| !Array.isArray(data.data)) {
|
|
76
|
+
throw new Error(`${PROVIDERS[provider].label} returned an unsupported model-list response.`);
|
|
77
|
+
}
|
|
78
|
+
return (data.data)
|
|
79
|
+
.filter((model) => (typeof model === "object"
|
|
80
|
+
&& model !== null
|
|
81
|
+
&& typeof model.id === "string"))
|
|
82
|
+
.map((model) => ({
|
|
83
|
+
id: model.id,
|
|
84
|
+
name: typeof model.name === "string" ? model.name : model.id,
|
|
85
|
+
free: isExplicitlyFree(model),
|
|
86
|
+
supportsTools: Array.isArray(model.supported_parameters)
|
|
87
|
+
? model.supported_parameters.includes("tools")
|
|
88
|
+
: undefined,
|
|
89
|
+
contextLength: Number(model.context_length ?? model.context_window) || 0,
|
|
90
|
+
type: typeof model.type === "string" ? model.type : undefined,
|
|
91
|
+
}))
|
|
92
|
+
.sort((left, right) => Number(right.free) - Number(left.free) || left.id.localeCompare(right.id));
|
|
93
|
+
}
|
|
94
|
+
function isChatModel(model) {
|
|
95
|
+
if (typeof model.type === "string" && !["chat", "text"].includes(model.type.toLowerCase())) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
return !/(embedding|rerank|moderation|whisper|transcri|text-to-speech|tts|image-gen)/i.test(model.id);
|
|
99
|
+
}
|
|
100
|
+
function reliabilityScore(model) {
|
|
101
|
+
let score = 0;
|
|
102
|
+
const id = model.id.toLowerCase();
|
|
103
|
+
if (/(instruct|chat|versatile)/.test(id))
|
|
104
|
+
score += 12;
|
|
105
|
+
if (/(preview|experimental|beta|deprecated|legacy)/.test(id))
|
|
106
|
+
score -= 20;
|
|
107
|
+
const size = id.match(/(?:^|[-/])(\d+(?:\.\d+)?)b(?:[-:/]|$)/);
|
|
108
|
+
if (size)
|
|
109
|
+
score += Math.min(Number(size[1]), 70) / 10;
|
|
110
|
+
score += Math.min(model.contextLength, 128_000) / 128_000;
|
|
111
|
+
return score;
|
|
112
|
+
}
|
|
113
|
+
export function selectReliableModel(provider, models) {
|
|
114
|
+
const chatModels = models.filter(isChatModel);
|
|
115
|
+
if (chatModels.length === 0) {
|
|
116
|
+
throw new Error(`${PROVIDERS[provider].label} did not return any chat models.`);
|
|
117
|
+
}
|
|
118
|
+
const freeModels = chatModels.filter((model) => model.free);
|
|
119
|
+
const candidates = freeModels.length ? freeModels : chatModels;
|
|
120
|
+
const toolModels = candidates.filter((model) => model.supportsTools === true);
|
|
121
|
+
const compatible = toolModels.length
|
|
122
|
+
? toolModels
|
|
123
|
+
: candidates.filter((model) => model.supportsTools !== false);
|
|
124
|
+
if (compatible.length === 0) {
|
|
125
|
+
throw new Error(`${PROVIDERS[provider].label} has no available chat model that supports tool calls.`);
|
|
126
|
+
}
|
|
127
|
+
const preferred = PREFERRED_MODELS[provider];
|
|
128
|
+
return [...compatible].sort((left, right) => {
|
|
129
|
+
const leftPreference = preferred.indexOf(left.id);
|
|
130
|
+
const rightPreference = preferred.indexOf(right.id);
|
|
131
|
+
const leftRank = leftPreference < 0 ? Number.MAX_SAFE_INTEGER : leftPreference;
|
|
132
|
+
const rightRank = rightPreference < 0 ? Number.MAX_SAFE_INTEGER : rightPreference;
|
|
133
|
+
return leftRank - rightRank
|
|
134
|
+
|| reliabilityScore(right) - reliabilityScore(left)
|
|
135
|
+
|| left.id.localeCompare(right.id);
|
|
136
|
+
})[0];
|
|
137
|
+
}
|
|
138
|
+
export async function chooseAvailableModel(provider, apiKey) {
|
|
139
|
+
const models = await discoverModels(provider, apiKey);
|
|
140
|
+
return selectReliableModel(provider, models);
|
|
141
|
+
}
|
|
142
|
+
function optionalNumber(value) {
|
|
143
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
144
|
+
return value;
|
|
145
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
146
|
+
const parsed = Number(value);
|
|
147
|
+
if (Number.isFinite(parsed))
|
|
148
|
+
return parsed;
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
function normalizeUsage(value) {
|
|
153
|
+
if (!value || typeof value !== "object")
|
|
154
|
+
return undefined;
|
|
155
|
+
const raw = value;
|
|
156
|
+
const prompt = optionalNumber(raw.prompt_tokens);
|
|
157
|
+
const completion = optionalNumber(raw.completion_tokens);
|
|
158
|
+
const total = optionalNumber(raw.total_tokens);
|
|
159
|
+
if (prompt === null && completion === null && total === null)
|
|
160
|
+
return undefined;
|
|
161
|
+
const promptTokens = prompt ?? 0;
|
|
162
|
+
const completionTokens = completion ?? 0;
|
|
163
|
+
const totalTokens = total ?? promptTokens + completionTokens;
|
|
164
|
+
const cost = optionalNumber(raw.cost);
|
|
165
|
+
return cost === null
|
|
166
|
+
? { promptTokens, completionTokens, totalTokens }
|
|
167
|
+
: { promptTokens, completionTokens, totalTokens, cost };
|
|
168
|
+
}
|
|
169
|
+
async function readCreditJson(response) {
|
|
170
|
+
try {
|
|
171
|
+
return await response.json();
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
export async function fetchAvailableCredits(apiKey) {
|
|
178
|
+
const headers = { Authorization: `Bearer ${apiKey}` };
|
|
179
|
+
try {
|
|
180
|
+
const response = await fetch("https://openrouter.ai/api/v1/key", {
|
|
181
|
+
headers,
|
|
182
|
+
signal: AbortSignal.timeout(10_000),
|
|
183
|
+
});
|
|
184
|
+
if (response.ok) {
|
|
185
|
+
const { data } = (await readCreditJson(response) ?? {});
|
|
186
|
+
const remaining = optionalNumber(data?.limit_remaining);
|
|
187
|
+
if (remaining !== null) {
|
|
188
|
+
return { remaining: Math.max(0, remaining), source: "key" };
|
|
189
|
+
}
|
|
190
|
+
const limit = optionalNumber(data?.limit);
|
|
191
|
+
const usage = optionalNumber(data?.usage) ?? 0;
|
|
192
|
+
if (limit !== null && limit > 0) {
|
|
193
|
+
return { remaining: Math.max(0, limit - usage), source: "key" };
|
|
194
|
+
}
|
|
195
|
+
const budgetRemaining = optionalNumber(data?.effective_budget?.remaining_usd);
|
|
196
|
+
if (budgetRemaining !== null) {
|
|
197
|
+
return { remaining: Math.max(0, budgetRemaining), source: "budget" };
|
|
198
|
+
}
|
|
199
|
+
const budgets = Array.isArray(data?.budgets) ? data.budgets : [];
|
|
200
|
+
const remainingBudgets = budgets
|
|
201
|
+
.map((budget) => optionalNumber(budget?.remaining_usd))
|
|
202
|
+
.filter((value) => value !== null);
|
|
203
|
+
if (remainingBudgets.length > 0) {
|
|
204
|
+
return { remaining: Math.max(0, Math.min(...remainingBudgets)), source: "budget" };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// Credit status is optional; the footer can fall back to context usage.
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
const response = await fetch("https://openrouter.ai/api/v1/credits", {
|
|
213
|
+
headers,
|
|
214
|
+
signal: AbortSignal.timeout(10_000),
|
|
215
|
+
});
|
|
216
|
+
if (response.ok) {
|
|
217
|
+
const { data } = (await readCreditJson(response) ?? {});
|
|
218
|
+
const total = optionalNumber(data?.total_credits);
|
|
219
|
+
const used = optionalNumber(data?.total_usage) ?? 0;
|
|
220
|
+
if (total !== null) {
|
|
221
|
+
return { remaining: Math.max(0, total - used), source: "account" };
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// Account credits require a management key and may not be available.
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
export async function createChatCompletion({ provider, apiKey, model, messages, tools, }) {
|
|
231
|
+
const payload = {
|
|
232
|
+
model,
|
|
233
|
+
messages,
|
|
234
|
+
max_tokens: 4096,
|
|
235
|
+
};
|
|
236
|
+
if (tools) {
|
|
237
|
+
payload.tools = tools;
|
|
238
|
+
payload.tool_choice = "auto";
|
|
239
|
+
}
|
|
240
|
+
const response = await providerFetch(PROVIDERS[provider].endpoint, provider, apiKey, {
|
|
241
|
+
method: "POST",
|
|
242
|
+
headers: { "Content-Type": "application/json" },
|
|
243
|
+
body: JSON.stringify(payload),
|
|
244
|
+
});
|
|
245
|
+
const data = await response.json();
|
|
246
|
+
const message = data.choices?.[0]?.message;
|
|
247
|
+
if (!message)
|
|
248
|
+
throw new Error(`${PROVIDERS[provider].label} returned no assistant message.`);
|
|
249
|
+
return { message, usage: normalizeUsage(data.usage) };
|
|
250
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { realpath, stat } from "node:fs/promises";
|
|
3
|
+
const SECRET_FILE = /(^|[/\\])\.env(?:\..*)?$|(?:^|[/\\])[^/\\]*(?:secret|credential|private[-_]?key)[^/\\]*|\.pem$/i;
|
|
4
|
+
export function isSensitivePath(relativePath) {
|
|
5
|
+
const normalized = relativePath.replaceAll("\\", "/");
|
|
6
|
+
return SECRET_FILE.test(normalized) && !/(^|\/)\.env\.example$/i.test(normalized);
|
|
7
|
+
}
|
|
8
|
+
export async function resolveWorkspacePath(workspace, requestedPath, { mustExist = false } = {}) {
|
|
9
|
+
if (typeof requestedPath !== "string" || requestedPath.length === 0 || requestedPath.includes("\0")) {
|
|
10
|
+
throw new Error("A non-empty relative path is required.");
|
|
11
|
+
}
|
|
12
|
+
if (path.isAbsolute(requestedPath) || /^[a-z]:/i.test(requestedPath)) {
|
|
13
|
+
throw new Error("Absolute paths are not allowed.");
|
|
14
|
+
}
|
|
15
|
+
const root = await realpath(workspace);
|
|
16
|
+
const target = path.resolve(root, requestedPath);
|
|
17
|
+
const relative = path.relative(root, target);
|
|
18
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
19
|
+
throw new Error("The path must stay inside the workspace.");
|
|
20
|
+
}
|
|
21
|
+
if (isSensitivePath(relative)) {
|
|
22
|
+
throw new Error("Access to secret-like files is blocked.");
|
|
23
|
+
}
|
|
24
|
+
let existing = target;
|
|
25
|
+
while (true) {
|
|
26
|
+
try {
|
|
27
|
+
existing = await realpath(existing);
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (error.code !== "ENOENT")
|
|
32
|
+
throw error;
|
|
33
|
+
const parent = path.dirname(existing);
|
|
34
|
+
if (parent === existing)
|
|
35
|
+
throw error;
|
|
36
|
+
existing = parent;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const resolvedRelative = path.relative(root, existing);
|
|
40
|
+
if (resolvedRelative === ".." || resolvedRelative.startsWith(`..${path.sep}`) || path.isAbsolute(resolvedRelative)) {
|
|
41
|
+
throw new Error("The path resolves outside the workspace.");
|
|
42
|
+
}
|
|
43
|
+
if (mustExist) {
|
|
44
|
+
await stat(target);
|
|
45
|
+
return realpath(target);
|
|
46
|
+
}
|
|
47
|
+
return target;
|
|
48
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { PROVIDER_IDS } from "./providers.js";
|
|
6
|
+
import { DEFAULT_EFFORT, DEFAULT_MODE, EFFORT_LEVELS, MODE_OPTIONS, } from "./types.js";
|
|
7
|
+
export const SESSION_SCHEMA = 1;
|
|
8
|
+
const SESSION_ID = /^[a-z0-9]{4,64}$/i;
|
|
9
|
+
export function createSessionId() {
|
|
10
|
+
return randomBytes(4).toString("hex");
|
|
11
|
+
}
|
|
12
|
+
export function isValidSessionId(id) {
|
|
13
|
+
return SESSION_ID.test(id);
|
|
14
|
+
}
|
|
15
|
+
export function sessionDirectory() {
|
|
16
|
+
if (process.platform === "win32") {
|
|
17
|
+
const base = process.env.LOCALAPPDATA?.trim()
|
|
18
|
+
|| path.join(os.homedir(), "AppData", "Local");
|
|
19
|
+
return path.join(base, "ChelsCode", "sessions");
|
|
20
|
+
}
|
|
21
|
+
const base = process.env.XDG_STATE_HOME?.trim()
|
|
22
|
+
|| path.join(os.homedir(), ".local", "state");
|
|
23
|
+
return path.join(base, "chelscode", "sessions");
|
|
24
|
+
}
|
|
25
|
+
export function resumeHint(id) {
|
|
26
|
+
return `Resume this session with:\n chelscode --id ${id}`;
|
|
27
|
+
}
|
|
28
|
+
export function sameWorkspace(left, right) {
|
|
29
|
+
const a = path.resolve(left);
|
|
30
|
+
const b = path.resolve(right);
|
|
31
|
+
return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
32
|
+
}
|
|
33
|
+
function sessionFile(id, directory) {
|
|
34
|
+
if (!isValidSessionId(id))
|
|
35
|
+
throw new Error(`Invalid session id: ${id}`);
|
|
36
|
+
return path.join(directory, `${id}.json`);
|
|
37
|
+
}
|
|
38
|
+
function isRecord(value) {
|
|
39
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
40
|
+
}
|
|
41
|
+
function isProvider(value) {
|
|
42
|
+
return typeof value === "string" && PROVIDER_IDS.includes(value);
|
|
43
|
+
}
|
|
44
|
+
function isWorkTask(value) {
|
|
45
|
+
if (!isRecord(value))
|
|
46
|
+
return false;
|
|
47
|
+
return typeof value.title === "string"
|
|
48
|
+
&& typeof value.instructions === "string"
|
|
49
|
+
&& isProvider(value.provider);
|
|
50
|
+
}
|
|
51
|
+
function isMadeItem(value) {
|
|
52
|
+
if (!isRecord(value))
|
|
53
|
+
return false;
|
|
54
|
+
if (value.kind === "file")
|
|
55
|
+
return typeof value.path === "string";
|
|
56
|
+
if (value.kind === "command")
|
|
57
|
+
return typeof value.command === "string";
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
function isTranscriptEntry(value) {
|
|
61
|
+
if (!isRecord(value) || typeof value.id !== "number")
|
|
62
|
+
return false;
|
|
63
|
+
switch (value.kind) {
|
|
64
|
+
case "header":
|
|
65
|
+
return typeof value.workspace === "string"
|
|
66
|
+
&& typeof value.version === "string"
|
|
67
|
+
&& (value.resumedFrom === undefined || typeof value.resumedFrom === "string");
|
|
68
|
+
case "user":
|
|
69
|
+
case "note":
|
|
70
|
+
return typeof value.text === "string";
|
|
71
|
+
case "plan":
|
|
72
|
+
return Array.isArray(value.tasks) && value.tasks.every(isWorkTask);
|
|
73
|
+
case "done":
|
|
74
|
+
return typeof value.text === "string"
|
|
75
|
+
&& Array.isArray(value.made)
|
|
76
|
+
&& value.made.every(isMadeItem);
|
|
77
|
+
case "error":
|
|
78
|
+
return typeof value.title === "string" && typeof value.message === "string";
|
|
79
|
+
default:
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function isMode(value) {
|
|
84
|
+
return typeof value === "string" && MODE_OPTIONS.includes(value);
|
|
85
|
+
}
|
|
86
|
+
function isEffort(value) {
|
|
87
|
+
return typeof value === "string" && EFFORT_LEVELS.includes(value);
|
|
88
|
+
}
|
|
89
|
+
function nonNegative(value) {
|
|
90
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
|
|
91
|
+
}
|
|
92
|
+
function parseSnapshot(value, expectedId) {
|
|
93
|
+
if (!isRecord(value))
|
|
94
|
+
throw new Error(`Session ${expectedId} is not valid.`);
|
|
95
|
+
if (value.schema !== SESSION_SCHEMA) {
|
|
96
|
+
throw new Error(`Session ${expectedId} uses an unsupported format.`);
|
|
97
|
+
}
|
|
98
|
+
if (value.id !== expectedId)
|
|
99
|
+
throw new Error(`Session ${expectedId} has a mismatched id.`);
|
|
100
|
+
if (typeof value.workspace !== "string" || !value.workspace.trim()) {
|
|
101
|
+
throw new Error(`Session ${expectedId} has no workspace.`);
|
|
102
|
+
}
|
|
103
|
+
if (typeof value.createdAt !== "string" || typeof value.updatedAt !== "string") {
|
|
104
|
+
throw new Error(`Session ${expectedId} has no timestamps.`);
|
|
105
|
+
}
|
|
106
|
+
if (!Array.isArray(value.entries) || !value.entries.every(isTranscriptEntry)) {
|
|
107
|
+
throw new Error(`Session ${expectedId} has an unreadable transcript.`);
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
schema: SESSION_SCHEMA,
|
|
111
|
+
id: expectedId,
|
|
112
|
+
workspace: value.workspace,
|
|
113
|
+
createdAt: value.createdAt,
|
|
114
|
+
updatedAt: value.updatedAt,
|
|
115
|
+
mode: isMode(value.mode) ? value.mode : DEFAULT_MODE,
|
|
116
|
+
effort: isEffort(value.effort) ? value.effort : DEFAULT_EFFORT,
|
|
117
|
+
tokensUsed: nonNegative(value.tokensUsed),
|
|
118
|
+
creditsUsed: nonNegative(value.creditsUsed),
|
|
119
|
+
creditsAvailable: value.creditsAvailable === null || value.creditsAvailable === undefined
|
|
120
|
+
? null
|
|
121
|
+
: nonNegative(value.creditsAvailable),
|
|
122
|
+
entries: value.entries,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
export function saveSession(snapshot, directory = sessionDirectory()) {
|
|
126
|
+
const file = sessionFile(snapshot.id, directory);
|
|
127
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
128
|
+
const temp = `${file}.${process.pid}.${randomBytes(3).toString("hex")}.tmp`;
|
|
129
|
+
writeFileSync(temp, `${JSON.stringify(snapshot, null, 2)}\n`, {
|
|
130
|
+
encoding: "utf8",
|
|
131
|
+
mode: 0o600,
|
|
132
|
+
});
|
|
133
|
+
renameSync(temp, file);
|
|
134
|
+
}
|
|
135
|
+
export function loadSession(id, directory = sessionDirectory()) {
|
|
136
|
+
const file = sessionFile(id, directory);
|
|
137
|
+
let text;
|
|
138
|
+
try {
|
|
139
|
+
text = readFileSync(file, "utf8");
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (error.code === "ENOENT") {
|
|
143
|
+
throw new Error(`Session ${id} was not found.`);
|
|
144
|
+
}
|
|
145
|
+
throw new Error(`Could not read session ${id}: ${errorMessage(error)}`);
|
|
146
|
+
}
|
|
147
|
+
let parsed;
|
|
148
|
+
try {
|
|
149
|
+
parsed = JSON.parse(text);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
throw new Error(`Session ${id} is not valid JSON.`);
|
|
153
|
+
}
|
|
154
|
+
return parseSnapshot(parsed, id);
|
|
155
|
+
}
|
|
156
|
+
export function findLatestSession(workspace, directory = sessionDirectory()) {
|
|
157
|
+
let files;
|
|
158
|
+
try {
|
|
159
|
+
files = readdirSync(directory);
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
if (error.code === "ENOENT")
|
|
163
|
+
return null;
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
let latest = null;
|
|
167
|
+
for (const file of files) {
|
|
168
|
+
if (!file.endsWith(".json"))
|
|
169
|
+
continue;
|
|
170
|
+
const id = file.slice(0, -".json".length);
|
|
171
|
+
if (!isValidSessionId(id))
|
|
172
|
+
continue;
|
|
173
|
+
try {
|
|
174
|
+
const snapshot = loadSession(id, directory);
|
|
175
|
+
if (workspace && !sameWorkspace(snapshot.workspace, workspace))
|
|
176
|
+
continue;
|
|
177
|
+
if (!latest || snapshot.updatedAt > latest.updatedAt)
|
|
178
|
+
latest = snapshot;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// Ignore malformed or unreadable session files.
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return latest;
|
|
185
|
+
}
|
|
186
|
+
function errorMessage(error) {
|
|
187
|
+
return error instanceof Error ? error.message : String(error);
|
|
188
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { stdout } from "node:process";
|
|
2
|
+
const OSC = "\u001b]";
|
|
3
|
+
const BEL = "\u0007";
|
|
4
|
+
/** Remove control characters so a saved title cannot inject terminal escapes. */
|
|
5
|
+
export function cleanTitle(title) {
|
|
6
|
+
return title.replace(/[\u0000-\u001f\u007f]/g, "");
|
|
7
|
+
}
|
|
8
|
+
/** OSC 0 updates both the window title and the tab title. */
|
|
9
|
+
export function titleSequence(title) {
|
|
10
|
+
return `${OSC}0;${cleanTitle(title)}${BEL}`;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Set the terminal title while ChelsCode runs and return a function that
|
|
14
|
+
* restores the title that was there before. Only touches interactive output.
|
|
15
|
+
*/
|
|
16
|
+
export function installTerminalTitle(title, hooks = {}) {
|
|
17
|
+
const output = hooks.output ?? stdout;
|
|
18
|
+
const platform = hooks.platform ?? process.platform;
|
|
19
|
+
const readTitle = hooks.readTitle ?? (() => process.title);
|
|
20
|
+
const writeTitle = hooks.writeTitle ?? ((next) => {
|
|
21
|
+
process.title = next;
|
|
22
|
+
});
|
|
23
|
+
if (!output.isTTY)
|
|
24
|
+
return () => { };
|
|
25
|
+
const previous = platform === "win32" ? readTitle() : "";
|
|
26
|
+
if (platform === "win32")
|
|
27
|
+
writeTitle(title);
|
|
28
|
+
output.write(titleSequence(title));
|
|
29
|
+
let restored = false;
|
|
30
|
+
return () => {
|
|
31
|
+
if (restored)
|
|
32
|
+
return;
|
|
33
|
+
restored = true;
|
|
34
|
+
if (platform === "win32") {
|
|
35
|
+
writeTitle(previous);
|
|
36
|
+
output.write(titleSequence(previous));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
output.write(`${OSC}0;${BEL}`);
|
|
40
|
+
};
|
|
41
|
+
}
|