@giovannijecha/jecode 0.8.5 → 0.8.6
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 +22 -5
- package/dist/batch-view.js +27 -2
- package/dist/batch.js +2 -0
- package/dist/cli-info.js +0 -1
- package/dist/config.js +14 -6
- package/dist/credential-commands.js +3 -3
- package/dist/openai-account-command.js +14 -12
- package/dist/openai-account.js +7 -5
- package/dist/openai-oauth-callback.js +13 -4
- package/dist/openai-oauth-tokens.js +9 -7
- package/dist/openai-oauth.js +8 -6
- package/dist/permission-command.js +1 -1
- package/dist/provider-commands.js +1 -33
- package/dist/provider-errors.js +1 -10
- package/dist/provider-label.js +3 -10
- package/dist/providers/anthropic.js +0 -1
- package/dist/providers/index.js +1 -5
- package/dist/providers/ollama-context.js +42 -0
- package/dist/providers/ollama-endpoint.js +7 -34
- package/dist/providers/ollama.js +13 -147
- package/dist/providers/openai-codex.js +4 -4
- package/dist/providers/openai.js +0 -1
- package/dist/sessions/bucket.js +55 -0
- package/dist/sessions/catalog-io.js +162 -0
- package/dist/sessions/catalog.js +3 -1
- package/dist/sessions/codec-messages.js +122 -0
- package/dist/sessions/codec-transcript.js +93 -0
- package/dist/sessions/codec-values.js +52 -0
- package/dist/sessions/codec.js +4 -257
- package/dist/sessions/files.js +158 -0
- package/dist/sessions/load.js +90 -0
- package/dist/sessions/snapshot.js +33 -0
- package/dist/sessions/store.js +42 -461
- package/dist/settings-command.js +10 -5
- package/dist/settings.js +16 -15
- package/dist/start.js +1 -2
- package/dist/tools/file-read.js +192 -0
- package/dist/tools/file-summary.js +9 -0
- package/dist/tools/{fs.js → file-write.js} +5 -193
- package/dist/tools/glob.js +107 -0
- package/dist/tools/index.js +2 -1
- package/dist/tools/search.js +1 -105
- package/dist/tui/app-workflows.js +8 -361
- package/dist/tui/approve.js +5 -3
- package/dist/tui/blocks.js +8 -7
- package/dist/tui/command-workflow.js +106 -0
- package/dist/tui/components/command-menu.js +7 -10
- package/dist/tui/components/menu.js +74 -43
- package/dist/tui/components/messages.js +16 -9
- package/dist/tui/components/tool-evidence.js +107 -0
- package/dist/tui/components/tool-motion.js +32 -0
- package/dist/tui/components/tool.js +48 -202
- package/dist/tui/help.js +1 -1
- package/dist/tui/picker-layout.js +40 -0
- package/dist/tui/picker.js +7 -71
- package/dist/tui/tool-details.js +135 -0
- package/dist/tui/transcript-grammar.js +8 -1
- package/dist/tui/transcript-view.js +26 -112
- package/dist/tui/turn-workflow.js +264 -0
- package/dist/tui/turn.js +7 -140
- package/dist/tui/workflow-types.js +2 -0
- package/package.json +12 -12
- package/dist/ollama-settings-command.js +0 -74
- package/dist/tui/motion.js +0 -32
package/dist/sessions/store.js
CHANGED
|
@@ -3,160 +3,61 @@
|
|
|
3
3
|
// Each node is its own atomically replaced file. The head is advanced only
|
|
4
4
|
// after that node is durable, so a crash leaves either the prior checkpoint or
|
|
5
5
|
// one strictly recoverable mutation -- never an ambiguous partial history.
|
|
6
|
-
import {
|
|
7
|
-
import { lstat,
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { lstat, rename } from "node:fs/promises";
|
|
8
8
|
import * as path from "node:path";
|
|
9
9
|
import { atomicWrite } from "../atomic.js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import { assertDirectoryAnchor, captureDirectDirectory, createPrivateDirectory, preparePrivateDirectory, } from "../directory-anchor.js";
|
|
10
|
+
import { stableFileExpectation } from "../bounded-file.js";
|
|
11
|
+
import { assertDirectoryAnchor, captureDirectDirectory, createPrivateDirectory, } from "../directory-anchor.js";
|
|
13
12
|
import { sameFileIdentity } from "../file-identity.js";
|
|
14
|
-
import { readStableDirectory } from "../stable-directory.js";
|
|
15
13
|
import { userDataPath } from "../user-data.js";
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
14
|
+
import { assertSessionId, SessionBucket } from "./bucket.js";
|
|
15
|
+
import { SessionCatalogIO } from "./catalog-io.js";
|
|
16
|
+
import { advanceSessionCatalog, encodeSessionCatalog, sameSessionHead, sessionCatalog, SESSION_CATALOG_FILE, SESSION_CHECKPOINT_FILE, } from "./catalog.js";
|
|
17
|
+
import { decodeHead, encodeHead, encodeMeta, encodeNode, SESSION_FILE_LIMITS, SESSION_SCHEMA, } from "./codec.js";
|
|
18
18
|
import { claimLeaseDirectory, createLeaseDirectory, leaseFromGeneration, leaseOwner, leaseToken, pidIsAlive, removeLegacyLeaseExclusive, removeLease, sessionLease, sessionLeaseOwns, } from "./lease.js";
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const CATALOG_READ_CONCURRENCY = 8;
|
|
23
|
-
const MAX_NODE_READ_CONCURRENCY = 8;
|
|
24
|
-
const MAX_NODE_READ_IN_FLIGHT_BYTES = 64 * 1_024 * 1_024;
|
|
25
|
-
const MAX_SESSION_NODE_BYTES = 192 * 1_024 * 1_024;
|
|
26
|
-
const NODE_READ_CONCURRENCY = Math.max(1, Math.min(MAX_NODE_READ_CONCURRENCY, Math.floor(MAX_NODE_READ_IN_FLIGHT_BYTES / SESSION_FILE_LIMITS.nodeBytes)));
|
|
27
|
-
const SESSION_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
|
28
|
-
const NODE_NAME = /^(\d{6})\.json$/;
|
|
29
|
-
const ATOMIC_NODE_TEMP = /^\.\d{6}\.json\.\d+\.[a-f0-9-]+\.tmp$/;
|
|
19
|
+
import { assertMissingNode, DIRECTORY_MODE, FILE_MODE, nodeName, readJson, removeTemporaryDirectory, } from "./files.js";
|
|
20
|
+
import { loadSession } from "./load.js";
|
|
21
|
+
import { assertSharedNodes, assertSnapshot } from "./snapshot.js";
|
|
30
22
|
export class DurableSessionStore {
|
|
31
23
|
workspaceRoot;
|
|
32
24
|
workspaceDigest;
|
|
33
25
|
#bucket;
|
|
34
|
-
#
|
|
35
|
-
#bucketAnchor;
|
|
26
|
+
#catalog;
|
|
36
27
|
#hooks;
|
|
37
28
|
#leaseScope = Object.freeze({});
|
|
38
|
-
constructor(
|
|
39
|
-
this.workspaceRoot = workspaceRoot;
|
|
40
|
-
this.workspaceDigest =
|
|
41
|
-
this.#bucket =
|
|
42
|
-
this.#sessionsAnchor = sessionsAnchor;
|
|
43
|
-
this.#bucketAnchor = bucketAnchor;
|
|
29
|
+
constructor(bucket, hooks) {
|
|
30
|
+
this.workspaceRoot = bucket.workspaceRoot;
|
|
31
|
+
this.workspaceDigest = bucket.workspaceDigest;
|
|
32
|
+
this.#bucket = bucket;
|
|
44
33
|
this.#hooks = hooks;
|
|
34
|
+
this.#catalog = new SessionCatalogIO(bucket, {
|
|
35
|
+
claim: (id) => this.claim(id),
|
|
36
|
+
load: (id, lease) => this.load(id, lease),
|
|
37
|
+
});
|
|
45
38
|
}
|
|
46
39
|
static async open(workspaceRoot, sessionsRoot = userDataPath("sessions"), hooks = {}) {
|
|
47
|
-
|
|
48
|
-
const digest = digestWorkspace(canonical);
|
|
49
|
-
const sessionsAnchor = await preparePrivateDirectory(path.resolve(sessionsRoot), "session storage root", DIRECTORY_MODE);
|
|
50
|
-
const bucketAnchor = await preparePrivateDirectory(path.join(sessionsAnchor.path, digest), "workspace session directory", DIRECTORY_MODE);
|
|
51
|
-
return new DurableSessionStore(canonical, sessionsAnchor, bucketAnchor, hooks);
|
|
40
|
+
return new DurableSessionStore(await SessionBucket.open(workspaceRoot, sessionsRoot), hooks);
|
|
52
41
|
}
|
|
53
42
|
async list(limit = 32) {
|
|
54
|
-
|
|
55
|
-
throw new Error("session catalogue limit is invalid");
|
|
56
|
-
}
|
|
57
|
-
await this.#ensureBucket();
|
|
58
|
-
const names = await catalogNames(this.#bucketAnchor);
|
|
59
|
-
const catalog = [];
|
|
60
|
-
for (let start = 0; start < names.length; start += CATALOG_READ_CONCURRENCY) {
|
|
61
|
-
const batch = await Promise.all(names.slice(start, start + CATALOG_READ_CONCURRENCY)
|
|
62
|
-
.map(async (id) => {
|
|
63
|
-
return await this.#catalogEntry(id);
|
|
64
|
-
}));
|
|
65
|
-
catalog.push(...batch.filter((entry) => entry !== undefined));
|
|
66
|
-
}
|
|
67
|
-
return catalog
|
|
68
|
-
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) || right.id.localeCompare(left.id))
|
|
69
|
-
.slice(0, limit);
|
|
43
|
+
return this.#catalog.list(limit);
|
|
70
44
|
}
|
|
71
45
|
async load(id, recoveryLease) {
|
|
72
|
-
|
|
73
|
-
await this.#ensureBucket();
|
|
74
|
-
const directory = this.#sessionDirectory(id);
|
|
75
|
-
const directoryAnchor = await captureDirectDirectory(directory, "session directory");
|
|
76
|
-
const nodesAnchor = await captureDirectDirectory(path.join(directory, "nodes"), "session node directory");
|
|
77
|
-
const validateSession = async () => {
|
|
78
|
-
await Promise.all([
|
|
79
|
-
this.#ensureBucket(),
|
|
80
|
-
assertDirectoryAnchor(directoryAnchor),
|
|
81
|
-
assertDirectoryAnchor(nodesAnchor),
|
|
82
|
-
]);
|
|
83
|
-
};
|
|
84
|
-
const meta = decodeMeta(await readJson(path.join(directory, "meta.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateSession));
|
|
85
|
-
assertSessionWorkspace(meta, id, this.workspaceRoot, this.workspaceDigest);
|
|
86
|
-
const persistedHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateSession));
|
|
87
|
-
let head = persistedHead;
|
|
88
|
-
const stored = await readNodes(nodesAnchor, validateSession);
|
|
89
|
-
const ahead = stored.filter((entry) => entry.sequence > head.sequence);
|
|
90
|
-
if (ahead.some((entry) => entry.sequence !== head.sequence + 1) || ahead.length > 1) {
|
|
91
|
-
throw new Error("session has an ambiguous incomplete checkpoint");
|
|
92
|
-
}
|
|
93
|
-
const byId = new Map(stored.map((entry) => [entry.node.id, entry]));
|
|
94
|
-
const headed = byId.get(head.nodeId);
|
|
95
|
-
if (headed === undefined)
|
|
96
|
-
throw new Error("session head is missing its conversation node");
|
|
97
|
-
if (ahead.length === 0) {
|
|
98
|
-
if (headed.sequence !== head.sequence || headed.node.revision !== head.revision ||
|
|
99
|
-
headed.node.parentId !== head.parentId) {
|
|
100
|
-
throw new Error("session head does not match its conversation node");
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
else {
|
|
104
|
-
const candidate = ahead[0];
|
|
105
|
-
const replacesHead = candidate.node.id === head.nodeId &&
|
|
106
|
-
candidate.node.parentId === head.parentId &&
|
|
107
|
-
candidate.node.revision === head.revision + 1;
|
|
108
|
-
const candidateParent = byId.get(candidate.node.parentId);
|
|
109
|
-
const extendsTree = candidate.node.id === stored.length &&
|
|
110
|
-
candidate.node.revision === 1 && candidateParent !== undefined &&
|
|
111
|
-
candidateParent.sequence <= head.sequence;
|
|
112
|
-
if (!replacesHead && !extendsTree) {
|
|
113
|
-
throw new Error("session checkpoint cannot be recovered safely");
|
|
114
|
-
}
|
|
115
|
-
if (recoveryLease === undefined ||
|
|
116
|
-
!sessionLeaseOwns(recoveryLease, id, this.#leaseScope)) {
|
|
117
|
-
throw new Error("session recovery requires exclusive ownership");
|
|
118
|
-
}
|
|
119
|
-
await recoveryLease.assertOwned();
|
|
120
|
-
head = Object.freeze({
|
|
121
|
-
version: SESSION_SCHEMA,
|
|
122
|
-
sequence: candidate.sequence,
|
|
123
|
-
nodeId: candidate.node.id,
|
|
124
|
-
parentId: candidate.node.parentId,
|
|
125
|
-
revision: candidate.node.revision,
|
|
126
|
-
updatedAt: candidate.updatedAt,
|
|
127
|
-
});
|
|
128
|
-
await atomicWrite(path.join(directory, "head.json"), encodeHead(head), {
|
|
129
|
-
mode: FILE_MODE,
|
|
130
|
-
validate: async (phase) => {
|
|
131
|
-
await validateSession();
|
|
132
|
-
if (phase !== "before-rename")
|
|
133
|
-
return;
|
|
134
|
-
await recoveryLease.assertOwned();
|
|
135
|
-
const current = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateSession));
|
|
136
|
-
if (!sameSessionHead(current, persistedHead)) {
|
|
137
|
-
throw new Error("session head changed while recovering its checkpoint");
|
|
138
|
-
}
|
|
139
|
-
},
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
const nodes = stored.map((entry) => entry.node);
|
|
143
|
-
const conversation = ConversationTree.restore(nodes, head.nodeId);
|
|
144
|
-
const catalog = sessionCatalog(meta, head, conversation);
|
|
145
|
-
return Object.freeze({ meta, head, conversation, catalog });
|
|
46
|
+
return loadSession(this.#bucket, id, this.#leaseScope, recoveryLease);
|
|
146
47
|
}
|
|
147
48
|
async publish(conversation, claim, reservedId) {
|
|
148
49
|
const active = conversation.activeNode;
|
|
149
50
|
if (active === undefined)
|
|
150
51
|
throw new Error("an empty conversation cannot be persisted");
|
|
151
|
-
await this.#
|
|
52
|
+
await this.#bucket.assert();
|
|
152
53
|
const now = new Date().toISOString();
|
|
153
54
|
const id = reservedId ?? reserveSessionId(now);
|
|
154
55
|
assertSessionId(id);
|
|
155
56
|
const token = claim === true ? leaseToken() : undefined;
|
|
156
57
|
let leaseGeneration;
|
|
157
58
|
let temporaryAnchor;
|
|
158
|
-
const temporary = path.join(this.#bucket, `.${id}.${randomUUID()}.tmp`);
|
|
159
|
-
const target = this.#
|
|
59
|
+
const temporary = path.join(this.#bucket.anchor.path, `.${id}.${randomUUID()}.tmp`);
|
|
60
|
+
const target = this.#bucket.directory(id);
|
|
160
61
|
const meta = Object.freeze({
|
|
161
62
|
version: SESSION_SCHEMA,
|
|
162
63
|
id,
|
|
@@ -178,7 +79,7 @@ export class DurableSessionStore {
|
|
|
178
79
|
const nodes = path.join(temporary, "nodes");
|
|
179
80
|
const nodesAnchor = await createPrivateDirectory(nodes, "temporary session node directory", DIRECTORY_MODE);
|
|
180
81
|
const validateTemporary = async () => {
|
|
181
|
-
await this.#
|
|
82
|
+
await this.#bucket.assert();
|
|
182
83
|
await assertDirectoryAnchor(temporaryAnchor);
|
|
183
84
|
await assertDirectoryAnchor(nodesAnchor);
|
|
184
85
|
};
|
|
@@ -200,14 +101,14 @@ export class DurableSessionStore {
|
|
|
200
101
|
}
|
|
201
102
|
await validateTemporary();
|
|
202
103
|
await rename(temporary, target);
|
|
203
|
-
await this.#
|
|
104
|
+
await this.#bucket.assert();
|
|
204
105
|
const targetAnchor = await captureDirectDirectory(target, "session directory");
|
|
205
106
|
if (!sameFileIdentity(temporaryAnchor.identity, targetAnchor.identity)) {
|
|
206
107
|
throw new Error("session directory changed while publishing");
|
|
207
108
|
}
|
|
208
109
|
}
|
|
209
110
|
catch (error) {
|
|
210
|
-
await removeTemporaryDirectory(temporary, this.#
|
|
111
|
+
await removeTemporaryDirectory(temporary, this.#bucket.anchor, temporaryAnchor)
|
|
211
112
|
.catch(() => undefined);
|
|
212
113
|
throw error;
|
|
213
114
|
}
|
|
@@ -226,14 +127,14 @@ export class DurableSessionStore {
|
|
|
226
127
|
throw new Error("session checkpoint requires exclusive ownership");
|
|
227
128
|
}
|
|
228
129
|
await lease.assertOwned();
|
|
229
|
-
const directory = this.#
|
|
230
|
-
const directoryAnchor = await this.#
|
|
130
|
+
const directory = this.#bucket.directory(id);
|
|
131
|
+
const directoryAnchor = await this.#bucket.captureSession(id);
|
|
231
132
|
const nodesDirectory = path.join(directory, "nodes");
|
|
232
133
|
const nodesAnchor = await captureDirectDirectory(nodesDirectory, "session node directory");
|
|
233
134
|
const validateNodesDirectory = async () => {
|
|
234
135
|
await Promise.all([
|
|
235
136
|
lease.assertOwned(),
|
|
236
|
-
this.#
|
|
137
|
+
this.#bucket.assert(),
|
|
237
138
|
assertDirectoryAnchor(directoryAnchor),
|
|
238
139
|
assertDirectoryAnchor(nodesAnchor),
|
|
239
140
|
]);
|
|
@@ -305,6 +206,13 @@ export class DurableSessionStore {
|
|
|
305
206
|
if (extendsTree) {
|
|
306
207
|
await assertMissingNode(path.join(nodesDirectory, nodeName(active.id)), validateNodesDirectory);
|
|
307
208
|
}
|
|
209
|
+
// Publish the next head's index first. Until the head commits, listing
|
|
210
|
+
// treats it as suspect and loads the tree after ownership is released.
|
|
211
|
+
// A failed node/head write must never leave an apparently current index.
|
|
212
|
+
await atomicWrite(path.join(directory, SESSION_CATALOG_FILE), encodeSessionCatalog(catalog), {
|
|
213
|
+
mode: FILE_MODE,
|
|
214
|
+
validate: async () => assertBaseHead(),
|
|
215
|
+
});
|
|
308
216
|
await atomicWrite(path.join(nodesDirectory, nodeName(active.id)), encodeNode(active, head.sequence, now), {
|
|
309
217
|
mode: FILE_MODE,
|
|
310
218
|
validate: async (phase) => {
|
|
@@ -318,7 +226,6 @@ export class DurableSessionStore {
|
|
|
318
226
|
mode: FILE_MODE,
|
|
319
227
|
validate: async () => assertBaseHead(),
|
|
320
228
|
});
|
|
321
|
-
await this.#writeCatalog(previous.meta.id, catalog, checkpointToken).catch(() => undefined);
|
|
322
229
|
return Object.freeze({ meta: previous.meta, head, conversation, catalog });
|
|
323
230
|
}
|
|
324
231
|
catch (error) {
|
|
@@ -339,10 +246,10 @@ export class DurableSessionStore {
|
|
|
339
246
|
}
|
|
340
247
|
async claim(id) {
|
|
341
248
|
assertSessionId(id);
|
|
342
|
-
const directory = this.#
|
|
343
|
-
const directoryAnchor = await this.#
|
|
249
|
+
const directory = this.#bucket.directory(id);
|
|
250
|
+
const directoryAnchor = await this.#bucket.captureSession(id);
|
|
344
251
|
const validateDirectory = async () => {
|
|
345
|
-
await this.#
|
|
252
|
+
await this.#bucket.assertSession(directoryAnchor);
|
|
346
253
|
};
|
|
347
254
|
await validateDirectory();
|
|
348
255
|
const file = path.join(directory, "active");
|
|
@@ -386,333 +293,7 @@ export class DurableSessionStore {
|
|
|
386
293
|
}
|
|
387
294
|
return owned;
|
|
388
295
|
}
|
|
389
|
-
#sessionDirectory(id) {
|
|
390
|
-
return path.join(this.#bucket, id);
|
|
391
|
-
}
|
|
392
|
-
async #sessionAnchor(id) {
|
|
393
|
-
await this.#ensureBucket();
|
|
394
|
-
return captureDirectDirectory(this.#sessionDirectory(id), "session directory");
|
|
395
|
-
}
|
|
396
|
-
async #assertSessionAnchor(anchor) {
|
|
397
|
-
await Promise.all([this.#ensureBucket(), assertDirectoryAnchor(anchor)]);
|
|
398
|
-
}
|
|
399
|
-
async #ensureBucket() {
|
|
400
|
-
await Promise.all([
|
|
401
|
-
assertDirectoryAnchor(this.#sessionsAnchor),
|
|
402
|
-
assertDirectoryAnchor(this.#bucketAnchor),
|
|
403
|
-
]);
|
|
404
|
-
}
|
|
405
|
-
async #leaseIsActive(id, directory) {
|
|
406
|
-
if (directory !== undefined)
|
|
407
|
-
await this.#assertSessionAnchor(directory);
|
|
408
|
-
else
|
|
409
|
-
await this.#ensureBucket();
|
|
410
|
-
const owner = await leaseOwner(path.join(this.#sessionDirectory(id), "active"));
|
|
411
|
-
if (directory !== undefined)
|
|
412
|
-
await this.#assertSessionAnchor(directory);
|
|
413
|
-
return owner !== undefined && pidIsAlive(owner.pid);
|
|
414
|
-
}
|
|
415
|
-
async #catalogEntry(id) {
|
|
416
|
-
try {
|
|
417
|
-
const directory = this.#sessionDirectory(id);
|
|
418
|
-
const directoryAnchor = await this.#sessionAnchor(id);
|
|
419
|
-
const validateDirectory = async () => {
|
|
420
|
-
await this.#assertSessionAnchor(directoryAnchor);
|
|
421
|
-
};
|
|
422
|
-
const checkpointFile = path.join(directory, SESSION_CHECKPOINT_FILE);
|
|
423
|
-
// A second head read closes the only useful race: a checkpoint landing
|
|
424
|
-
// between the small record reads. A changing marker gets one retry.
|
|
425
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
426
|
-
await validateDirectory();
|
|
427
|
-
const checkpointBefore = await leaseOwner(checkpointFile);
|
|
428
|
-
try {
|
|
429
|
-
const [metaValue, headValue, catalogValue] = await Promise.all([
|
|
430
|
-
readJson(path.join(directory, "meta.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateDirectory),
|
|
431
|
-
readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateDirectory),
|
|
432
|
-
readJson(path.join(directory, SESSION_CATALOG_FILE), SESSION_CATALOG_BYTES, undefined, validateDirectory),
|
|
433
|
-
]);
|
|
434
|
-
const meta = decodeMeta(metaValue);
|
|
435
|
-
const head = decodeHead(headValue);
|
|
436
|
-
const storedCatalog = decodeSessionCatalog(catalogValue);
|
|
437
|
-
assertSessionWorkspace(meta, id, this.workspaceRoot, this.workspaceDigest);
|
|
438
|
-
const confirmedHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateDirectory));
|
|
439
|
-
await validateDirectory();
|
|
440
|
-
const checkpointAfter = await leaseOwner(checkpointFile);
|
|
441
|
-
if (!sameLease(checkpointBefore, checkpointAfter))
|
|
442
|
-
continue;
|
|
443
|
-
if (!sameSessionHead(head, confirmedHead) ||
|
|
444
|
-
!catalogMatches(storedCatalog, meta, head))
|
|
445
|
-
break;
|
|
446
|
-
if (checkpointAfter !== undefined && !pidIsAlive(checkpointAfter.pid))
|
|
447
|
-
break;
|
|
448
|
-
const active = await this.#leaseIsActive(id, directoryAnchor) ||
|
|
449
|
-
checkpointAfter !== undefined;
|
|
450
|
-
return catalogEntry(storedCatalog, active);
|
|
451
|
-
}
|
|
452
|
-
catch {
|
|
453
|
-
break;
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
// Missing, stale, or malformed summaries are rebuilt only while the
|
|
457
|
-
// session is idle. Selecting a session still performs this strict load.
|
|
458
|
-
const checkpoint = await leaseOwner(checkpointFile);
|
|
459
|
-
if (await this.#leaseIsActive(id, directoryAnchor) ||
|
|
460
|
-
(checkpoint !== undefined && pidIsAlive(checkpoint.pid)))
|
|
461
|
-
return undefined;
|
|
462
|
-
const repairLease = await this.claim(id);
|
|
463
|
-
let snapshot;
|
|
464
|
-
try {
|
|
465
|
-
snapshot = await this.load(id, repairLease);
|
|
466
|
-
await this.#writeCatalog(id, snapshot.catalog, checkpoint?.legacy === true ? undefined : checkpoint?.token).catch(() => undefined);
|
|
467
|
-
}
|
|
468
|
-
finally {
|
|
469
|
-
await repairLease.close();
|
|
470
|
-
}
|
|
471
|
-
const currentCheckpoint = await leaseOwner(checkpointFile);
|
|
472
|
-
const active = await this.#leaseIsActive(id, directoryAnchor) ||
|
|
473
|
-
(currentCheckpoint !== undefined && pidIsAlive(currentCheckpoint.pid));
|
|
474
|
-
return catalogEntry(snapshot.catalog, active);
|
|
475
|
-
}
|
|
476
|
-
catch {
|
|
477
|
-
// Corrupt, unsafe, active-without-a-summary, or foreign data never
|
|
478
|
-
// becomes a resume candidate.
|
|
479
|
-
return undefined;
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
async #writeCatalog(id, catalog, checkpointToken) {
|
|
483
|
-
const directory = this.#sessionDirectory(id);
|
|
484
|
-
const directoryAnchor = await this.#sessionAnchor(id);
|
|
485
|
-
const validateDirectory = async () => {
|
|
486
|
-
await this.#assertSessionAnchor(directoryAnchor);
|
|
487
|
-
};
|
|
488
|
-
const validate = async () => {
|
|
489
|
-
await validateDirectory();
|
|
490
|
-
const currentHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateDirectory));
|
|
491
|
-
if (!sameSessionHead(currentHead, catalog.head)) {
|
|
492
|
-
throw new Error("session head changed while updating its catalogue");
|
|
493
|
-
}
|
|
494
|
-
};
|
|
495
|
-
await atomicWrite(path.join(directory, SESSION_CATALOG_FILE), encodeSessionCatalog(catalog), { mode: FILE_MODE, validate });
|
|
496
|
-
if (checkpointToken !== undefined) {
|
|
497
|
-
await validateDirectory();
|
|
498
|
-
await removeLease(path.join(directory, SESSION_CHECKPOINT_FILE), checkpointToken);
|
|
499
|
-
await validateDirectory();
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
async function catalogNames(directory) {
|
|
504
|
-
await assertDirectoryAnchor(directory);
|
|
505
|
-
const inspected = await readStableDirectory(directory.path, directory.path, {
|
|
506
|
-
maxEntries: MAX_CATALOG_ENTRIES + 1,
|
|
507
|
-
});
|
|
508
|
-
if (inspected.capped || inspected.entries.length > MAX_CATALOG_ENTRIES) {
|
|
509
|
-
throw new Error(`session catalogue exceeds ${MAX_CATALOG_ENTRIES} entries`);
|
|
510
|
-
}
|
|
511
|
-
return inspected.entries
|
|
512
|
-
.filter((entry) => entry.kind === "directory" && SESSION_NAME.test(entry.name))
|
|
513
|
-
.map((entry) => entry.name)
|
|
514
|
-
.sort((left, right) => right.localeCompare(left));
|
|
515
|
-
}
|
|
516
|
-
function catalogEntry(catalog, active) {
|
|
517
|
-
if (catalog.resumeNodeId === 0)
|
|
518
|
-
return undefined;
|
|
519
|
-
return Object.freeze({
|
|
520
|
-
id: catalog.id,
|
|
521
|
-
createdAt: catalog.createdAt,
|
|
522
|
-
updatedAt: catalog.head.updatedAt,
|
|
523
|
-
turns: catalog.turns,
|
|
524
|
-
preview: catalog.preview,
|
|
525
|
-
active,
|
|
526
|
-
});
|
|
527
|
-
}
|
|
528
|
-
function sameLease(left, right) {
|
|
529
|
-
return left?.token === right?.token && left?.legacy === right?.legacy;
|
|
530
|
-
}
|
|
531
|
-
function assertSessionWorkspace(meta, id, workspaceRoot, workspaceDigest) {
|
|
532
|
-
if (meta.id !== id || meta.workspaceDigest !== workspaceDigest ||
|
|
533
|
-
workspaceKey(meta.workspaceRoot) !== workspaceKey(workspaceRoot))
|
|
534
|
-
throw new Error("session belongs to a different workspace");
|
|
535
|
-
}
|
|
536
|
-
function assertSharedNodes(previous, next, replacedId) {
|
|
537
|
-
for (const node of previous.nodes) {
|
|
538
|
-
if (node.id === replacedId)
|
|
539
|
-
continue;
|
|
540
|
-
const candidate = next.node(node.id);
|
|
541
|
-
if (candidate !== node) {
|
|
542
|
-
throw new Error("session checkpoint rewrites prior conversation history");
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
function assertSnapshot(snapshot, workspaceRoot, workspaceDigest) {
|
|
547
|
-
encodeMeta(snapshot.meta);
|
|
548
|
-
encodeHead(snapshot.head);
|
|
549
|
-
encodeSessionCatalog(snapshot.catalog);
|
|
550
|
-
assertSessionId(snapshot.meta.id);
|
|
551
|
-
if (snapshot.meta.workspaceDigest !== workspaceDigest ||
|
|
552
|
-
workspaceKey(snapshot.meta.workspaceRoot) !== workspaceKey(workspaceRoot))
|
|
553
|
-
throw new Error("session snapshot belongs to a different workspace");
|
|
554
|
-
const active = snapshot.conversation.activeNode;
|
|
555
|
-
if (active === undefined ||
|
|
556
|
-
active.id !== snapshot.head.nodeId ||
|
|
557
|
-
active.parentId !== snapshot.head.parentId ||
|
|
558
|
-
active.revision !== snapshot.head.revision ||
|
|
559
|
-
snapshot.head.sequence < snapshot.conversation.nodes.length)
|
|
560
|
-
throw new Error("session snapshot does not match its verified head");
|
|
561
|
-
if (!catalogMatches(snapshot.catalog, snapshot.meta, snapshot.head)) {
|
|
562
|
-
throw new Error("session snapshot does not match its verified catalogue");
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
async function assertMissingNode(file, validate) {
|
|
566
|
-
await validate?.();
|
|
567
|
-
try {
|
|
568
|
-
await lstat(file);
|
|
569
|
-
}
|
|
570
|
-
catch (error) {
|
|
571
|
-
if (error.code === "ENOENT") {
|
|
572
|
-
await validate?.();
|
|
573
|
-
return;
|
|
574
|
-
}
|
|
575
|
-
throw error;
|
|
576
|
-
}
|
|
577
|
-
await validate?.();
|
|
578
|
-
throw new Error("session has an incomplete node outside its verified snapshot");
|
|
579
|
-
}
|
|
580
|
-
async function readNodes(directory, validate) {
|
|
581
|
-
await validate();
|
|
582
|
-
await assertDirectoryAnchor(directory);
|
|
583
|
-
const names = [];
|
|
584
|
-
let entries = 0;
|
|
585
|
-
const handle = await opendir(directory.path);
|
|
586
|
-
for await (const entry of handle) {
|
|
587
|
-
entries++;
|
|
588
|
-
if (entries > CONVERSATION_LIMITS.nodes + 64) {
|
|
589
|
-
throw new Error("session node directory contains unsupported data");
|
|
590
|
-
}
|
|
591
|
-
if (!entry.isFile() || (!NODE_NAME.test(entry.name) && !ATOMIC_NODE_TEMP.test(entry.name))) {
|
|
592
|
-
throw new Error("session node directory contains unsupported data");
|
|
593
|
-
}
|
|
594
|
-
if (NODE_NAME.test(entry.name))
|
|
595
|
-
names.push(entry.name);
|
|
596
|
-
}
|
|
597
|
-
await validate();
|
|
598
|
-
await assertDirectoryAnchor(directory);
|
|
599
|
-
names.sort();
|
|
600
|
-
if (names.length === 0 || names.length > CONVERSATION_LIMITS.nodes) {
|
|
601
|
-
throw new Error("session has an invalid conversation size");
|
|
602
|
-
}
|
|
603
|
-
const files = [];
|
|
604
|
-
let storedBytes = 0;
|
|
605
|
-
for (let index = 0; index < names.length; index++) {
|
|
606
|
-
const name = names[index];
|
|
607
|
-
const id = Number(NODE_NAME.exec(name)?.[1]);
|
|
608
|
-
if (id !== index + 1) {
|
|
609
|
-
throw new Error("session conversation nodes are not contiguous");
|
|
610
|
-
}
|
|
611
|
-
const details = await lstat(path.join(directory.path, name), { bigint: true });
|
|
612
|
-
if (details.isSymbolicLink() || !details.isFile() || details.size < 0n ||
|
|
613
|
-
details.size > BigInt(SESSION_FILE_LIMITS.nodeBytes))
|
|
614
|
-
throw new Error("session node file is unsafe or too large");
|
|
615
|
-
storedBytes += Number(details.size);
|
|
616
|
-
if (storedBytes > MAX_SESSION_NODE_BYTES) {
|
|
617
|
-
throw new Error("session node files exceed their aggregate storage limit");
|
|
618
|
-
}
|
|
619
|
-
files.push({ name, id, expected: stableFileExpectation(details) });
|
|
620
|
-
}
|
|
621
|
-
await validate();
|
|
622
|
-
const stored = [];
|
|
623
|
-
const sequences = new Set();
|
|
624
|
-
let messageCodeUnits = 0;
|
|
625
|
-
let transcriptCodeUnits = 0;
|
|
626
|
-
let contextCodeUnits = 0;
|
|
627
|
-
for (let start = 0; start < files.length; start += NODE_READ_CONCURRENCY) {
|
|
628
|
-
const decoded = await Promise.all(files.slice(start, start + NODE_READ_CONCURRENCY)
|
|
629
|
-
.map(async ({ name, id, expected }) => {
|
|
630
|
-
const entry = decodeNode(await readJson(path.join(directory.path, name), SESSION_FILE_LIMITS.nodeBytes, expected));
|
|
631
|
-
if (entry.node.id !== id) {
|
|
632
|
-
throw new Error("session conversation node identity is invalid");
|
|
633
|
-
}
|
|
634
|
-
return entry;
|
|
635
|
-
}));
|
|
636
|
-
for (const entry of decoded) {
|
|
637
|
-
if (sequences.has(entry.sequence)) {
|
|
638
|
-
throw new Error("session conversation node identity is invalid");
|
|
639
|
-
}
|
|
640
|
-
messageCodeUnits += JSON.stringify(entry.node.messages).length;
|
|
641
|
-
transcriptCodeUnits += JSON.stringify(entry.node.blocks).length;
|
|
642
|
-
contextCodeUnits += entry.node.context?.summary.length ?? 0;
|
|
643
|
-
if (messageCodeUnits > CONVERSATION_LIMITS.messageCodeUnits ||
|
|
644
|
-
transcriptCodeUnits > CONVERSATION_LIMITS.transcriptCodeUnits ||
|
|
645
|
-
contextCodeUnits > CONVERSATION_LIMITS.contextCodeUnits) {
|
|
646
|
-
throw new Error("session conversation exceeds its aggregate limit");
|
|
647
|
-
}
|
|
648
|
-
sequences.add(entry.sequence);
|
|
649
|
-
stored.push(entry);
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
await validate();
|
|
653
|
-
return stored;
|
|
654
|
-
}
|
|
655
|
-
async function readJson(file, limit, expected, validate) {
|
|
656
|
-
try {
|
|
657
|
-
return JSON.parse(await readBoundedText(file, limit, {
|
|
658
|
-
label: "session file",
|
|
659
|
-
expected,
|
|
660
|
-
validate,
|
|
661
|
-
}));
|
|
662
|
-
}
|
|
663
|
-
catch (error) {
|
|
664
|
-
if (error instanceof SyntaxError) {
|
|
665
|
-
throw new Error("session file is not valid JSON");
|
|
666
|
-
}
|
|
667
|
-
if (error instanceof BoundedFileError) {
|
|
668
|
-
throw new Error("session file is unsafe or too large, or changed while opening");
|
|
669
|
-
}
|
|
670
|
-
throw error;
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
async function removeTemporaryDirectory(directory, bucket, expected) {
|
|
674
|
-
const relative = path.relative(bucket.path, directory);
|
|
675
|
-
if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative) ||
|
|
676
|
-
!path.basename(directory).startsWith(".") || !path.basename(directory).endsWith(".tmp"))
|
|
677
|
-
throw new Error("refusing to remove an unverified session directory");
|
|
678
|
-
await assertDirectoryAnchor(bucket);
|
|
679
|
-
let observed;
|
|
680
|
-
try {
|
|
681
|
-
observed = await captureDirectDirectory(directory, "temporary session directory");
|
|
682
|
-
}
|
|
683
|
-
catch (error) {
|
|
684
|
-
if (error.code === "ENOENT")
|
|
685
|
-
return;
|
|
686
|
-
throw error;
|
|
687
|
-
}
|
|
688
|
-
if (expected !== undefined &&
|
|
689
|
-
!sameFileIdentity(expected.identity, observed.identity))
|
|
690
|
-
throw new Error("refusing to remove a replaced session directory");
|
|
691
|
-
const quarantine = path.join(bucket.path, `.discard-${process.pid}-${randomUUID()}.tmp`);
|
|
692
|
-
await assertDirectoryAnchor(bucket);
|
|
693
|
-
await rename(directory, quarantine);
|
|
694
|
-
const moved = await captureDirectDirectory(quarantine, "discarded session directory");
|
|
695
|
-
if (!sameFileIdentity(observed.identity, moved.identity)) {
|
|
696
|
-
throw new Error("session cleanup target changed during quarantine");
|
|
697
|
-
}
|
|
698
|
-
await assertDirectoryAnchor(bucket);
|
|
699
|
-
await assertDirectoryAnchor(moved);
|
|
700
|
-
await rm(quarantine, { recursive: true });
|
|
701
|
-
}
|
|
702
|
-
function nodeName(id) {
|
|
703
|
-
return `${String(id).padStart(6, "0")}.json`;
|
|
704
296
|
}
|
|
705
297
|
export function reserveSessionId(now = new Date().toISOString()) {
|
|
706
298
|
return `${now.replace(/[-:.]/g, "").replace("Z", "Z")}-${randomUUID()}`;
|
|
707
299
|
}
|
|
708
|
-
function digestWorkspace(workspaceRoot) {
|
|
709
|
-
return createHash("sha256").update(workspaceKey(workspaceRoot)).digest("hex");
|
|
710
|
-
}
|
|
711
|
-
function workspaceKey(workspaceRoot) {
|
|
712
|
-
const normalized = path.normalize(workspaceRoot);
|
|
713
|
-
return process.platform === "win32" ? normalized.toLocaleLowerCase("en-US") : normalized;
|
|
714
|
-
}
|
|
715
|
-
function assertSessionId(id) {
|
|
716
|
-
if (!SESSION_NAME.test(id))
|
|
717
|
-
throw new Error("session id is invalid");
|
|
718
|
-
}
|
package/dist/settings-command.js
CHANGED
|
@@ -69,26 +69,31 @@ function settingsItems(values) {
|
|
|
69
69
|
option: {
|
|
70
70
|
label: "model",
|
|
71
71
|
value: `${providerLabel(values.provider)} · ${values.model || "choose a model"}`,
|
|
72
|
+
description: "Choose the provider and model used for subsequent requests.",
|
|
72
73
|
},
|
|
73
74
|
},
|
|
74
|
-
{ action: "effort", option: { label: "effort", value: values.effort
|
|
75
|
+
{ action: "effort", option: { label: "effort", value: values.effort,
|
|
76
|
+
description: "Set the saved reasoning effort for model requests." } },
|
|
75
77
|
...(values.maxTokens === undefined
|
|
76
78
|
? []
|
|
77
79
|
: [{
|
|
78
80
|
action: "maxTokens",
|
|
79
|
-
option: { label: "max output tokens", value: String(values.maxTokens)
|
|
81
|
+
option: { label: "max output tokens", value: String(values.maxTokens),
|
|
82
|
+
description: "Limit the output tokens requested from the model in each response." },
|
|
80
83
|
}]),
|
|
81
84
|
{
|
|
82
85
|
action: "compactionPercent",
|
|
83
|
-
option: { label: "context compaction", value: `${values.compactionPercent}
|
|
86
|
+
option: { label: "context compaction", value: `${values.compactionPercent}%`,
|
|
87
|
+
description: "Compact model context near this share of its usable window. The transcript stays complete." },
|
|
84
88
|
},
|
|
85
89
|
{
|
|
86
90
|
action: "reducedMotion",
|
|
87
|
-
option: { label: "reduced motion", value: values.reducedMotion ? "on" : "off"
|
|
91
|
+
option: { label: "reduced motion", value: values.reducedMotion ? "on" : "off",
|
|
92
|
+
description: "Keep decorative movement still while output continues to update." },
|
|
88
93
|
},
|
|
89
94
|
{
|
|
90
95
|
action: "providers",
|
|
91
|
-
option: { label: "providers", hint: "manage connections" },
|
|
96
|
+
option: { label: "providers", hint: "manage connections", description: "Manage API keys and account connections." },
|
|
92
97
|
},
|
|
93
98
|
];
|
|
94
99
|
}
|