@giovannijecha/jecode 0.3.2 → 0.5.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/README.md +63 -23
- package/dist/batch.js +22 -2
- package/dist/cli-info.js +3 -0
- package/dist/command-settings.js +19 -0
- package/dist/commands.js +11 -14
- package/dist/config.js +2 -0
- package/dist/controller.js +3 -0
- package/dist/conversation.js +208 -0
- package/dist/credential-commands.js +63 -80
- package/dist/credentials.js +7 -1
- package/dist/launch.js +19 -0
- package/dist/model-command.js +171 -0
- package/dist/permission-command.js +52 -53
- package/dist/provider-commands.js +71 -228
- package/dist/provider-errors.js +4 -3
- package/dist/provider-label.js +2 -2
- package/dist/providers/ollama.js +8 -3
- package/dist/sessions/codec.js +344 -0
- package/dist/sessions/lease.js +76 -0
- package/dist/sessions/runtime.js +73 -0
- package/dist/sessions/store.js +368 -0
- package/dist/settings-command.js +43 -98
- package/dist/start.js +67 -4
- package/dist/transcript-types.js +6 -0
- package/dist/tui/activity.js +3 -0
- package/dist/tui/app-input.js +9 -6
- package/dist/tui/app-workflows.js +37 -8
- package/dist/tui/app.js +72 -20
- package/dist/tui/components/composer.js +1 -1
- package/dist/tui/components/footer.js +1 -1
- package/dist/tui/components/menu.js +27 -15
- package/dist/tui/components/messages.js +1 -15
- package/dist/tui/components/prompt.js +2 -2
- package/dist/tui/components/status.js +5 -2
- package/dist/tui/components/tool.js +22 -5
- package/dist/tui/editor.js +54 -7
- package/dist/tui/feedback.js +5 -2
- package/dist/tui/field.js +1 -1
- package/dist/tui/help.js +4 -1
- package/dist/tui/input.js +4 -0
- package/dist/tui/keys.js +14 -3
- package/dist/tui/overlay.js +6 -0
- package/dist/tui/picker.js +26 -10
- package/dist/tui/resume.js +24 -0
- package/dist/tui/session-view.js +2 -2
- package/dist/tui/turn.js +2 -3
- package/dist/tui/view.js +1 -1
- package/dist/ui/diff.js +2 -0
- package/dist/ui/inline.js +2 -2
- package/dist/ui/markdown.js +7 -7
- package/dist/ui/render.js +2 -0
- package/dist/ui/theme.js +4 -4
- package/dist/usage.js +9 -0
- package/package.json +1 -1
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
// Durable, workspace-scoped conversation storage.
|
|
2
|
+
//
|
|
3
|
+
// Each node is its own atomically replaced file. The head is advanced only
|
|
4
|
+
// after that node is durable, so a crash leaves either the prior checkpoint or
|
|
5
|
+
// one strictly recoverable mutation -- never an ambiguous partial history.
|
|
6
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
+
import { chmod, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, } from "node:fs/promises";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import { atomicWrite } from "../atomic.js";
|
|
10
|
+
import { CONVERSATION_LIMITS, ConversationTree } from "../conversation.js";
|
|
11
|
+
import { userDataPath } from "../user-data.js";
|
|
12
|
+
import { decodeHead, decodeMeta, decodeNode, encodeHead, encodeMeta, encodeNode, } from "./codec.js";
|
|
13
|
+
import { leaseOwner, leaseToken, pidIsAlive, removeLease, sessionLease, } from "./lease.js";
|
|
14
|
+
const DIRECTORY_MODE = 0o700;
|
|
15
|
+
const FILE_MODE = 0o600;
|
|
16
|
+
const MAX_CATALOG_SCAN = 128;
|
|
17
|
+
const MAX_JSON_BYTES = 20 * 1024 * 1024;
|
|
18
|
+
const SESSION_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
|
19
|
+
const NODE_NAME = /^(\d{6})\.json$/;
|
|
20
|
+
const ATOMIC_NODE_TEMP = /^\.\d{6}\.json\.\d+\.[a-f0-9-]+\.tmp$/;
|
|
21
|
+
export class DurableSessionStore {
|
|
22
|
+
workspaceRoot;
|
|
23
|
+
workspaceDigest;
|
|
24
|
+
#sessionsRoot;
|
|
25
|
+
#bucket;
|
|
26
|
+
constructor(workspaceRoot, sessionsRoot) {
|
|
27
|
+
this.workspaceRoot = workspaceRoot;
|
|
28
|
+
this.workspaceDigest = digestWorkspace(workspaceRoot);
|
|
29
|
+
this.#sessionsRoot = sessionsRoot;
|
|
30
|
+
this.#bucket = path.join(sessionsRoot, this.workspaceDigest);
|
|
31
|
+
}
|
|
32
|
+
static async open(workspaceRoot, sessionsRoot = userDataPath("sessions")) {
|
|
33
|
+
const canonical = await realpath(path.resolve(workspaceRoot));
|
|
34
|
+
return new DurableSessionStore(canonical, path.resolve(sessionsRoot));
|
|
35
|
+
}
|
|
36
|
+
async list(limit = 32) {
|
|
37
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64) {
|
|
38
|
+
throw new Error("session catalogue limit is invalid");
|
|
39
|
+
}
|
|
40
|
+
const entries = await directoryEntries(this.#bucket);
|
|
41
|
+
const names = entries
|
|
42
|
+
.filter((entry) => entry.isDirectory() && SESSION_NAME.test(entry.name))
|
|
43
|
+
.map((entry) => entry.name)
|
|
44
|
+
.sort((left, right) => right.localeCompare(left))
|
|
45
|
+
.slice(0, MAX_CATALOG_SCAN);
|
|
46
|
+
const catalog = [];
|
|
47
|
+
for (const id of names) {
|
|
48
|
+
try {
|
|
49
|
+
const snapshot = await this.load(id);
|
|
50
|
+
const conversation = snapshot.conversation.latestCompleted();
|
|
51
|
+
if (conversation === undefined)
|
|
52
|
+
continue;
|
|
53
|
+
catalog.push({
|
|
54
|
+
id,
|
|
55
|
+
createdAt: snapshot.meta.createdAt,
|
|
56
|
+
updatedAt: snapshot.head.updatedAt,
|
|
57
|
+
turns: selectedTurnCount(conversation),
|
|
58
|
+
preview: firstUserText(conversation),
|
|
59
|
+
active: await this.#leaseIsActive(id),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Corrupt or foreign data never becomes a resume candidate.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return catalog
|
|
67
|
+
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
|
|
68
|
+
.slice(0, limit);
|
|
69
|
+
}
|
|
70
|
+
async load(id) {
|
|
71
|
+
assertSessionId(id);
|
|
72
|
+
const directory = this.#sessionDirectory(id);
|
|
73
|
+
await assertDirectory(directory);
|
|
74
|
+
const meta = decodeMeta(await readJson(path.join(directory, "meta.json"), 64 * 1024));
|
|
75
|
+
if (meta.id !== id || meta.workspaceDigest !== this.workspaceDigest ||
|
|
76
|
+
workspaceKey(meta.workspaceRoot) !== workspaceKey(this.workspaceRoot))
|
|
77
|
+
throw new Error("session belongs to a different workspace");
|
|
78
|
+
let head = decodeHead(await readJson(path.join(directory, "head.json"), 64 * 1024));
|
|
79
|
+
const stored = await readNodes(path.join(directory, "nodes"));
|
|
80
|
+
const ahead = stored.filter((entry) => entry.sequence > head.sequence);
|
|
81
|
+
if (ahead.some((entry) => entry.sequence !== head.sequence + 1) || ahead.length > 1) {
|
|
82
|
+
throw new Error("session has an ambiguous incomplete checkpoint");
|
|
83
|
+
}
|
|
84
|
+
const byId = new Map(stored.map((entry) => [entry.node.id, entry]));
|
|
85
|
+
const headed = byId.get(head.nodeId);
|
|
86
|
+
if (headed === undefined)
|
|
87
|
+
throw new Error("session head is missing its conversation node");
|
|
88
|
+
if (ahead.length === 0) {
|
|
89
|
+
if (headed.sequence !== head.sequence || headed.node.revision !== head.revision ||
|
|
90
|
+
headed.node.parentId !== head.parentId) {
|
|
91
|
+
throw new Error("session head does not match its conversation node");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
const candidate = ahead[0];
|
|
96
|
+
const replacesHead = candidate.node.id === head.nodeId &&
|
|
97
|
+
candidate.node.parentId === head.parentId &&
|
|
98
|
+
candidate.node.revision === head.revision + 1;
|
|
99
|
+
const candidateParent = byId.get(candidate.node.parentId);
|
|
100
|
+
const extendsTree = candidate.node.id === stored.length &&
|
|
101
|
+
candidate.node.revision === 1 && candidateParent !== undefined &&
|
|
102
|
+
candidateParent.sequence <= head.sequence;
|
|
103
|
+
if (!replacesHead && !extendsTree) {
|
|
104
|
+
throw new Error("session checkpoint cannot be recovered safely");
|
|
105
|
+
}
|
|
106
|
+
head = {
|
|
107
|
+
version: 1,
|
|
108
|
+
sequence: candidate.sequence,
|
|
109
|
+
nodeId: candidate.node.id,
|
|
110
|
+
parentId: candidate.node.parentId,
|
|
111
|
+
revision: candidate.node.revision,
|
|
112
|
+
updatedAt: candidate.updatedAt,
|
|
113
|
+
};
|
|
114
|
+
await atomicWrite(path.join(directory, "head.json"), encodeHead(head), { mode: FILE_MODE });
|
|
115
|
+
}
|
|
116
|
+
const nodes = stored.map((entry) => entry.node);
|
|
117
|
+
const conversation = ConversationTree.restore(nodes, head.nodeId);
|
|
118
|
+
return Object.freeze({ meta, head, conversation });
|
|
119
|
+
}
|
|
120
|
+
async publish(conversation, claim) {
|
|
121
|
+
const active = conversation.activeNode;
|
|
122
|
+
if (active === undefined)
|
|
123
|
+
throw new Error("an empty conversation cannot be persisted");
|
|
124
|
+
await this.#ensureBucket();
|
|
125
|
+
const now = new Date().toISOString();
|
|
126
|
+
const id = sessionId(now);
|
|
127
|
+
const token = claim === true ? leaseToken() : undefined;
|
|
128
|
+
const temporary = path.join(this.#bucket, `.${id}.${randomUUID()}.tmp`);
|
|
129
|
+
const target = this.#sessionDirectory(id);
|
|
130
|
+
const meta = {
|
|
131
|
+
version: 1,
|
|
132
|
+
id,
|
|
133
|
+
workspaceRoot: this.workspaceRoot,
|
|
134
|
+
workspaceDigest: this.workspaceDigest,
|
|
135
|
+
createdAt: now,
|
|
136
|
+
};
|
|
137
|
+
const head = {
|
|
138
|
+
version: 1,
|
|
139
|
+
sequence: conversation.nodes.length,
|
|
140
|
+
nodeId: active.id,
|
|
141
|
+
parentId: active.parentId,
|
|
142
|
+
revision: active.revision,
|
|
143
|
+
updatedAt: now,
|
|
144
|
+
};
|
|
145
|
+
try {
|
|
146
|
+
await makePrivateDirectory(temporary);
|
|
147
|
+
const nodes = path.join(temporary, "nodes");
|
|
148
|
+
await makePrivateDirectory(nodes);
|
|
149
|
+
for (let index = 0; index < conversation.nodes.length; index++) {
|
|
150
|
+
const node = conversation.nodes[index];
|
|
151
|
+
await atomicWrite(path.join(nodes, nodeName(node.id)), encodeNode(node, index + 1, now), { mode: FILE_MODE });
|
|
152
|
+
}
|
|
153
|
+
await atomicWrite(path.join(temporary, "meta.json"), encodeMeta(meta), { mode: FILE_MODE });
|
|
154
|
+
await atomicWrite(path.join(temporary, "head.json"), encodeHead(head), { mode: FILE_MODE });
|
|
155
|
+
if (token !== undefined) {
|
|
156
|
+
await atomicWrite(path.join(temporary, "active"), token, { mode: FILE_MODE });
|
|
157
|
+
}
|
|
158
|
+
await rename(temporary, target);
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
await removeTemporaryDirectory(temporary, this.#bucket);
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
const snapshot = Object.freeze({ meta, head, conversation });
|
|
165
|
+
if (token === undefined)
|
|
166
|
+
return snapshot;
|
|
167
|
+
const lease = sessionLease(id, path.join(target, "active"), token);
|
|
168
|
+
return Object.freeze({ ...snapshot, lease });
|
|
169
|
+
}
|
|
170
|
+
async checkpoint(id, conversation) {
|
|
171
|
+
const previous = await this.load(id);
|
|
172
|
+
const active = conversation.activeNode;
|
|
173
|
+
if (active === undefined)
|
|
174
|
+
throw new Error("an empty conversation cannot be checkpointed");
|
|
175
|
+
if (sameNode(active, previous.conversation.activeNode)) {
|
|
176
|
+
return Object.freeze({ ...previous, conversation });
|
|
177
|
+
}
|
|
178
|
+
const replacesHead = active.id === previous.head.nodeId &&
|
|
179
|
+
active.revision === previous.head.revision + 1 &&
|
|
180
|
+
conversation.nodes.length === previous.conversation.nodes.length;
|
|
181
|
+
const extendsTree = active.id === previous.conversation.nodes.length + 1 &&
|
|
182
|
+
previous.conversation.node(active.parentId) !== undefined && active.revision === 1 &&
|
|
183
|
+
conversation.nodes.length === previous.conversation.nodes.length + 1;
|
|
184
|
+
if (!replacesHead && !extendsTree) {
|
|
185
|
+
throw new Error("session checkpoint does not extend its durable tree");
|
|
186
|
+
}
|
|
187
|
+
assertSharedNodes(previous.conversation, conversation, replacesHead ? active.id : undefined);
|
|
188
|
+
const now = new Date().toISOString();
|
|
189
|
+
const head = {
|
|
190
|
+
version: 1,
|
|
191
|
+
sequence: previous.head.sequence + 1,
|
|
192
|
+
nodeId: active.id,
|
|
193
|
+
parentId: active.parentId,
|
|
194
|
+
revision: active.revision,
|
|
195
|
+
updatedAt: now,
|
|
196
|
+
};
|
|
197
|
+
const directory = this.#sessionDirectory(id);
|
|
198
|
+
await atomicWrite(path.join(directory, "nodes", nodeName(active.id)), encodeNode(active, head.sequence, now), { mode: FILE_MODE });
|
|
199
|
+
await atomicWrite(path.join(directory, "head.json"), encodeHead(head), { mode: FILE_MODE });
|
|
200
|
+
return Object.freeze({ meta: previous.meta, head, conversation });
|
|
201
|
+
}
|
|
202
|
+
async claim(id) {
|
|
203
|
+
assertSessionId(id);
|
|
204
|
+
await assertDirectory(this.#sessionDirectory(id));
|
|
205
|
+
const file = path.join(this.#sessionDirectory(id), "active");
|
|
206
|
+
const token = leaseToken();
|
|
207
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
208
|
+
try {
|
|
209
|
+
const handle = await open(file, "wx", FILE_MODE);
|
|
210
|
+
try {
|
|
211
|
+
await handle.writeFile(token, "utf8");
|
|
212
|
+
await handle.sync();
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
await handle.close();
|
|
216
|
+
}
|
|
217
|
+
return sessionLease(id, file, token);
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
if (error.code !== "EEXIST")
|
|
221
|
+
throw error;
|
|
222
|
+
const owner = await leaseOwner(file);
|
|
223
|
+
if (owner !== undefined && pidIsAlive(owner.pid)) {
|
|
224
|
+
throw new Error("session is already open in another Jecode process");
|
|
225
|
+
}
|
|
226
|
+
if (owner !== undefined) {
|
|
227
|
+
await removeLease(file, owner.token);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
throw new Error("session could not be claimed");
|
|
232
|
+
}
|
|
233
|
+
#sessionDirectory(id) {
|
|
234
|
+
return path.join(this.#bucket, id);
|
|
235
|
+
}
|
|
236
|
+
async #ensureBucket() {
|
|
237
|
+
await makePrivateDirectory(this.#sessionsRoot);
|
|
238
|
+
await makePrivateDirectory(this.#bucket);
|
|
239
|
+
}
|
|
240
|
+
async #leaseIsActive(id) {
|
|
241
|
+
const owner = await leaseOwner(path.join(this.#sessionDirectory(id), "active"));
|
|
242
|
+
return owner !== undefined && pidIsAlive(owner.pid);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
function assertSharedNodes(previous, next, replacedId) {
|
|
246
|
+
for (const node of previous.nodes) {
|
|
247
|
+
if (node.id === replacedId)
|
|
248
|
+
continue;
|
|
249
|
+
const candidate = next.node(node.id);
|
|
250
|
+
if (candidate === undefined || normalizedNode(candidate) !== normalizedNode(node)) {
|
|
251
|
+
throw new Error("session checkpoint rewrites prior conversation history");
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function normalizedNode(node) {
|
|
256
|
+
const encoded = JSON.parse(encodeNode(node, 1, "2026-01-01T00:00:00.000Z"));
|
|
257
|
+
return JSON.stringify(encoded.node);
|
|
258
|
+
}
|
|
259
|
+
function sameNode(left, right) {
|
|
260
|
+
return right !== undefined && normalizedNode(left) === normalizedNode(right);
|
|
261
|
+
}
|
|
262
|
+
async function readNodes(directory) {
|
|
263
|
+
await assertDirectory(directory);
|
|
264
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
265
|
+
const names = entries.filter((entry) => entry.isFile() && NODE_NAME.test(entry.name))
|
|
266
|
+
.map((entry) => entry.name).sort();
|
|
267
|
+
if (names.length === 0 || names.length > CONVERSATION_LIMITS.nodes) {
|
|
268
|
+
throw new Error("session has an invalid conversation size");
|
|
269
|
+
}
|
|
270
|
+
if (entries.length > CONVERSATION_LIMITS.nodes + 64 ||
|
|
271
|
+
entries.some((entry) => !entry.isFile() || (!NODE_NAME.test(entry.name) && !ATOMIC_NODE_TEMP.test(entry.name)))) {
|
|
272
|
+
throw new Error("session node directory contains unsupported data");
|
|
273
|
+
}
|
|
274
|
+
const stored = [];
|
|
275
|
+
const sequences = new Set();
|
|
276
|
+
for (let index = 0; index < names.length; index++) {
|
|
277
|
+
const name = names[index];
|
|
278
|
+
const id = Number(NODE_NAME.exec(name)?.[1]);
|
|
279
|
+
if (id !== index + 1)
|
|
280
|
+
throw new Error("session conversation nodes are not contiguous");
|
|
281
|
+
const decoded = decodeNode(await readJson(path.join(directory, name), MAX_JSON_BYTES));
|
|
282
|
+
if (decoded.node.id !== id || sequences.has(decoded.sequence)) {
|
|
283
|
+
throw new Error("session conversation node identity is invalid");
|
|
284
|
+
}
|
|
285
|
+
sequences.add(decoded.sequence);
|
|
286
|
+
stored.push(decoded);
|
|
287
|
+
}
|
|
288
|
+
return stored;
|
|
289
|
+
}
|
|
290
|
+
async function readJson(file, limit) {
|
|
291
|
+
const details = await lstat(file);
|
|
292
|
+
if (details.isSymbolicLink() || !details.isFile() || details.size > limit) {
|
|
293
|
+
throw new Error("session file is unsafe or too large");
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
return JSON.parse(await readFile(file, "utf8"));
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
throw new Error("session file is not valid JSON");
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
async function assertDirectory(directory) {
|
|
303
|
+
const details = await lstat(directory);
|
|
304
|
+
if (details.isSymbolicLink() || !details.isDirectory()) {
|
|
305
|
+
throw new Error("session path is not a direct directory");
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
async function makePrivateDirectory(directory) {
|
|
309
|
+
await mkdir(directory, { recursive: true, mode: DIRECTORY_MODE });
|
|
310
|
+
await assertDirectory(directory);
|
|
311
|
+
if (process.platform !== "win32")
|
|
312
|
+
await chmod(directory, DIRECTORY_MODE);
|
|
313
|
+
}
|
|
314
|
+
async function directoryEntries(directory) {
|
|
315
|
+
try {
|
|
316
|
+
await assertDirectory(directory);
|
|
317
|
+
return await readdir(directory, { withFileTypes: true });
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
if (error.code === "ENOENT")
|
|
321
|
+
return [];
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
async function removeTemporaryDirectory(directory, bucket) {
|
|
326
|
+
const relative = path.relative(bucket, directory);
|
|
327
|
+
if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative) ||
|
|
328
|
+
!path.basename(directory).startsWith(".") || !path.basename(directory).endsWith(".tmp"))
|
|
329
|
+
throw new Error("refusing to remove an unverified session directory");
|
|
330
|
+
await rm(directory, { recursive: true, force: true });
|
|
331
|
+
}
|
|
332
|
+
function selectedTurnCount(conversation) {
|
|
333
|
+
let count = 0;
|
|
334
|
+
let id = conversation.activeNodeId;
|
|
335
|
+
while (id !== 0) {
|
|
336
|
+
count++;
|
|
337
|
+
id = conversation.node(id)?.parentId ?? 0;
|
|
338
|
+
}
|
|
339
|
+
return count;
|
|
340
|
+
}
|
|
341
|
+
function firstUserText(conversation) {
|
|
342
|
+
for (const message of conversation.history) {
|
|
343
|
+
if (message.role !== "user")
|
|
344
|
+
continue;
|
|
345
|
+
const text = message.content.find((block) => block.kind === "text")?.text
|
|
346
|
+
.replace(/\s+/gu, " ").trim();
|
|
347
|
+
if (text !== undefined && text !== "")
|
|
348
|
+
return text.slice(0, 160);
|
|
349
|
+
}
|
|
350
|
+
return "Untitled session";
|
|
351
|
+
}
|
|
352
|
+
function nodeName(id) {
|
|
353
|
+
return `${String(id).padStart(6, "0")}.json`;
|
|
354
|
+
}
|
|
355
|
+
function sessionId(now) {
|
|
356
|
+
return `${now.replace(/[-:.]/g, "").replace("Z", "Z")}-${randomUUID()}`;
|
|
357
|
+
}
|
|
358
|
+
function digestWorkspace(workspaceRoot) {
|
|
359
|
+
return createHash("sha256").update(workspaceKey(workspaceRoot)).digest("hex");
|
|
360
|
+
}
|
|
361
|
+
function workspaceKey(workspaceRoot) {
|
|
362
|
+
const normalized = path.normalize(workspaceRoot);
|
|
363
|
+
return process.platform === "win32" ? normalized.toLocaleLowerCase("en-US") : normalized;
|
|
364
|
+
}
|
|
365
|
+
function assertSessionId(id) {
|
|
366
|
+
if (!SESSION_NAME.test(id))
|
|
367
|
+
throw new Error("session id is invalid");
|
|
368
|
+
}
|
package/dist/settings-command.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
// The persistent settings hub.
|
|
2
|
-
//
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { EFFORTS, readSettings, settingsLabel, updateSettings } from "./settings.js";
|
|
1
|
+
// The persistent settings hub. Provider access and model selection reuse the
|
|
2
|
+
// same command flows exposed directly through /providers and /models.
|
|
3
|
+
import { saveCommandSettings } from "./command-settings.js";
|
|
4
|
+
import { modelsCommand } from "./model-command.js";
|
|
6
5
|
import { providerFailure } from "./provider-errors.js";
|
|
7
|
-
import {
|
|
6
|
+
import { providerLabel } from "./provider-label.js";
|
|
7
|
+
import { providersCommand } from "./provider-commands.js";
|
|
8
|
+
import { EFFORTS } from "./settings.js";
|
|
8
9
|
import { of } from "./tui/editor.js";
|
|
9
10
|
import { heading } from "./tui/picker.js";
|
|
10
11
|
/** A focused path to the same saved reasoning default exposed by /settings. */
|
|
@@ -22,7 +23,7 @@ export async function settingsCommand(session, host) {
|
|
|
22
23
|
while (true) {
|
|
23
24
|
const values = settingsValues(session);
|
|
24
25
|
const items = settingsItems(values);
|
|
25
|
-
const index = await choose(settingsPicker(values,
|
|
26
|
+
const index = await choose(settingsPicker(values, selected));
|
|
26
27
|
if (index === undefined)
|
|
27
28
|
return;
|
|
28
29
|
const action = items[index]?.action;
|
|
@@ -30,14 +31,8 @@ export async function settingsCommand(session, host) {
|
|
|
30
31
|
return;
|
|
31
32
|
selected = index;
|
|
32
33
|
switch (action) {
|
|
33
|
-
case "provider":
|
|
34
|
-
await providerSetting(session, host);
|
|
35
|
-
break;
|
|
36
|
-
case "ollamaConnection":
|
|
37
|
-
await ollamaConnectionSetting(session, host, (patch) => persist(host, patch));
|
|
38
|
-
break;
|
|
39
34
|
case "model":
|
|
40
|
-
await
|
|
35
|
+
await modelsCommand(session, host, { announce: false });
|
|
41
36
|
break;
|
|
42
37
|
case "effort":
|
|
43
38
|
await effortSetting(session, host);
|
|
@@ -51,45 +46,43 @@ export async function settingsCommand(session, host) {
|
|
|
51
46
|
case "reducedMotion":
|
|
52
47
|
await motionSetting(session, host);
|
|
53
48
|
break;
|
|
54
|
-
case "
|
|
55
|
-
await
|
|
49
|
+
case "providers":
|
|
50
|
+
await providersCommand(session, host);
|
|
56
51
|
break;
|
|
57
52
|
}
|
|
58
53
|
}
|
|
59
54
|
}
|
|
60
|
-
export function settingsPicker(values,
|
|
55
|
+
export function settingsPicker(values, index = 0) {
|
|
61
56
|
return {
|
|
62
|
-
title:
|
|
63
|
-
description: "Changes apply now · flags and environment win at launch",
|
|
57
|
+
title: [],
|
|
64
58
|
options: settingsItems(values).map((item) => item.option),
|
|
65
59
|
index,
|
|
66
60
|
};
|
|
67
61
|
}
|
|
68
62
|
function settingsItems(values) {
|
|
69
63
|
return [
|
|
70
|
-
{
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
{ action: "
|
|
78
|
-
{ action: "effort", option: { label: "effort", hint: values.effort } },
|
|
64
|
+
{
|
|
65
|
+
action: "model",
|
|
66
|
+
option: {
|
|
67
|
+
label: "model",
|
|
68
|
+
value: `${providerLabel(values.provider)} · ${values.model || "choose a model"}`,
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{ action: "effort", option: { label: "effort", value: values.effort } },
|
|
79
72
|
...(values.maxTokens === undefined
|
|
80
73
|
? []
|
|
81
74
|
: [{
|
|
82
75
|
action: "maxTokens",
|
|
83
|
-
option: { label: "max output tokens",
|
|
76
|
+
option: { label: "max output tokens", value: String(values.maxTokens) },
|
|
84
77
|
}]),
|
|
85
|
-
{ action: "maxSteps", option: { label: "max tool steps",
|
|
78
|
+
{ action: "maxSteps", option: { label: "max tool steps", value: String(values.maxSteps) } },
|
|
86
79
|
{
|
|
87
80
|
action: "reducedMotion",
|
|
88
|
-
option: { label: "reduced motion",
|
|
81
|
+
option: { label: "reduced motion", value: values.reducedMotion ? "on" : "off" },
|
|
89
82
|
},
|
|
90
83
|
{
|
|
91
|
-
action: "
|
|
92
|
-
option: { label: "
|
|
84
|
+
action: "providers",
|
|
85
|
+
option: { label: "providers", hint: "manage access and connections" },
|
|
93
86
|
},
|
|
94
87
|
];
|
|
95
88
|
}
|
|
@@ -97,65 +90,18 @@ function settingsValues(session) {
|
|
|
97
90
|
return {
|
|
98
91
|
provider: session.provider.id,
|
|
99
92
|
model: session.model,
|
|
100
|
-
...(session.provider.id === "ollama" ? { ollamaConnection: ollamaConnectionHint() } : {}),
|
|
101
93
|
effort: session.config.effort,
|
|
102
94
|
...(session.provider.id === "openai-codex" ? {} : { maxTokens: session.config.maxTokens }),
|
|
103
95
|
maxSteps: session.config.maxSteps,
|
|
104
96
|
reducedMotion: session.config.reducedMotion,
|
|
105
97
|
};
|
|
106
98
|
}
|
|
107
|
-
async function providerSetting(session, host) {
|
|
108
|
-
const before = {
|
|
109
|
-
provider: session.provider,
|
|
110
|
-
model: session.model,
|
|
111
|
-
providerId: session.config.providerId,
|
|
112
|
-
configModel: session.config.model,
|
|
113
|
-
effort: session.config.effort,
|
|
114
|
-
};
|
|
115
|
-
if (!(await providersCommand(session, host, { announce: false, save: false })))
|
|
116
|
-
return;
|
|
117
|
-
const current = readSettings();
|
|
118
|
-
const models = { ...current.models };
|
|
119
|
-
if (session.model !== "")
|
|
120
|
-
models[session.provider.id] = session.model;
|
|
121
|
-
const patch = {
|
|
122
|
-
provider: session.provider.id,
|
|
123
|
-
models,
|
|
124
|
-
...(session.config.effort === before.effort ? {} : { effort: session.config.effort }),
|
|
125
|
-
};
|
|
126
|
-
if (await persist(host, patch))
|
|
127
|
-
return;
|
|
128
|
-
session.provider = before.provider;
|
|
129
|
-
session.model = before.model;
|
|
130
|
-
session.config.providerId = before.providerId;
|
|
131
|
-
session.config.model = before.configModel;
|
|
132
|
-
session.config.effort = before.effort;
|
|
133
|
-
}
|
|
134
|
-
async function modelSetting(session, host) {
|
|
135
|
-
const before = {
|
|
136
|
-
model: session.model,
|
|
137
|
-
configModel: session.config.model,
|
|
138
|
-
effort: session.config.effort,
|
|
139
|
-
};
|
|
140
|
-
if (!(await modelsCommand(session, host, { announce: false, save: false })))
|
|
141
|
-
return;
|
|
142
|
-
const current = readSettings();
|
|
143
|
-
const models = { ...current.models, [session.provider.id]: session.model };
|
|
144
|
-
const patch = {
|
|
145
|
-
models,
|
|
146
|
-
...(session.config.effort === before.effort ? {} : { effort: session.config.effort }),
|
|
147
|
-
};
|
|
148
|
-
if (await persist(host, patch))
|
|
149
|
-
return;
|
|
150
|
-
session.model = before.model;
|
|
151
|
-
session.config.model = before.configModel;
|
|
152
|
-
session.config.effort = before.effort;
|
|
153
|
-
}
|
|
154
99
|
async function effortSetting(session, host) {
|
|
155
100
|
const choose = chooser(host);
|
|
156
101
|
if (choose === undefined)
|
|
157
102
|
return;
|
|
158
103
|
const efforts = await availableEfforts(session, host);
|
|
104
|
+
throwIfAborted(host.signal);
|
|
159
105
|
if (efforts === undefined)
|
|
160
106
|
return;
|
|
161
107
|
if (efforts.length === 0) {
|
|
@@ -168,12 +114,12 @@ async function effortSetting(session, host) {
|
|
|
168
114
|
}
|
|
169
115
|
const current = session.config.effort;
|
|
170
116
|
const index = await choose({
|
|
171
|
-
title:
|
|
117
|
+
title: [],
|
|
172
118
|
options: efforts.map((value) => ({ label: value })),
|
|
173
119
|
index: Math.max(0, efforts.findIndex((value) => value === current)),
|
|
174
120
|
});
|
|
175
121
|
const value = index === undefined ? undefined : efforts[index];
|
|
176
|
-
if (value === undefined || !(await
|
|
122
|
+
if (value === undefined || !(await saveCommandSettings(host, { effort: value })))
|
|
177
123
|
return;
|
|
178
124
|
session.config.effort = value;
|
|
179
125
|
return value;
|
|
@@ -181,11 +127,15 @@ async function effortSetting(session, host) {
|
|
|
181
127
|
async function availableEfforts(session, host) {
|
|
182
128
|
if (session.provider.efforts === undefined)
|
|
183
129
|
return EFFORTS;
|
|
184
|
-
host.status?.(`Asking ${session.provider.id}`);
|
|
130
|
+
host.status?.(`Asking ${providerLabel(session.provider.id)}`);
|
|
185
131
|
try {
|
|
186
|
-
|
|
132
|
+
throwIfAborted(host.signal);
|
|
133
|
+
const efforts = await session.provider.efforts(session.model, host.signal, (status) => host.status?.(status));
|
|
134
|
+
throwIfAborted(host.signal);
|
|
135
|
+
return efforts;
|
|
187
136
|
}
|
|
188
137
|
catch (error) {
|
|
138
|
+
throwIfAborted(host.signal);
|
|
189
139
|
host.emit({
|
|
190
140
|
kind: "notice",
|
|
191
141
|
text: providerFailure(session.provider, error, true),
|
|
@@ -203,12 +153,12 @@ async function motionSetting(session, host) {
|
|
|
203
153
|
return;
|
|
204
154
|
const values = [false, true];
|
|
205
155
|
const index = await choose({
|
|
206
|
-
title:
|
|
156
|
+
title: [],
|
|
207
157
|
options: values.map((value) => ({ label: value ? "on" : "off" })),
|
|
208
158
|
index: session.config.reducedMotion ? 1 : 0,
|
|
209
159
|
});
|
|
210
160
|
const value = index === undefined ? undefined : values[index];
|
|
211
|
-
if (value === undefined || !(await
|
|
161
|
+
if (value === undefined || !(await saveCommandSettings(host, { reducedMotion: value })))
|
|
212
162
|
return;
|
|
213
163
|
session.config.reducedMotion = value;
|
|
214
164
|
host.refreshSettings?.();
|
|
@@ -231,23 +181,18 @@ async function numberSetting(session, host, name, label) {
|
|
|
231
181
|
host.emit({ kind: "notice", text: `${label} must be a positive integer`, tone: "error" });
|
|
232
182
|
return;
|
|
233
183
|
}
|
|
234
|
-
if (!(await
|
|
184
|
+
if (!(await saveCommandSettings(host, { [name]: value })))
|
|
235
185
|
return;
|
|
236
186
|
session.config[name] = value;
|
|
237
187
|
}
|
|
238
|
-
async function persist(host, patch) {
|
|
239
|
-
try {
|
|
240
|
-
await updateSettings(patch);
|
|
241
|
-
return true;
|
|
242
|
-
}
|
|
243
|
-
catch (error) {
|
|
244
|
-
host.emit({ kind: "notice", text: `could not save settings · ${error.message}`, tone: "error" });
|
|
245
|
-
return false;
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
188
|
function chooser(host) {
|
|
249
189
|
if (host.choose === undefined) {
|
|
250
190
|
host.emit({ kind: "notice", text: "that command needs the screen", tone: "warn" });
|
|
251
191
|
}
|
|
252
192
|
return host.choose;
|
|
253
193
|
}
|
|
194
|
+
function throwIfAborted(signal) {
|
|
195
|
+
if (signal?.aborted !== true)
|
|
196
|
+
return;
|
|
197
|
+
throw signal.reason instanceof Error ? signal.reason : new Error("interrupted");
|
|
198
|
+
}
|