@giovannijecha/jecode 0.8.2 → 0.8.4
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 -280
- package/assets/wordmark-steel.svg +3 -0
- package/dist/accounts.js +17 -13
- package/dist/batch.js +66 -6
- package/dist/config.js +6 -3
- package/dist/context/budget.js +13 -1
- package/dist/context/compactor.js +5 -4
- package/dist/context/estimate.js +43 -1
- package/dist/context/manual.js +8 -4
- package/dist/context/policy.js +68 -18
- package/dist/controller-request.js +8 -8
- package/dist/controller.js +6 -1
- package/dist/conversation.js +94 -33
- package/dist/credential-safety.js +56 -9
- package/dist/credentials.js +32 -4
- package/dist/input-boundary.js +80 -0
- package/dist/main.js +4 -1
- package/dist/openai-oauth-callback.js +1 -1
- package/dist/process-shutdown.js +52 -0
- package/dist/provider-commands.js +43 -6
- package/dist/provider-errors.js +59 -1
- package/dist/providers/anthropic-stream.js +24 -20
- package/dist/providers/anthropic-wire.js +7 -2
- package/dist/providers/http.js +4 -34
- package/dist/providers/ollama-wire.js +7 -15
- package/dist/providers/ollama.js +1 -0
- package/dist/providers/openai-codex.js +1 -1
- package/dist/providers/openai-stream.js +43 -7
- package/dist/providers/openai-wire.js +2 -16
- package/dist/providers/openai.js +1 -1
- package/dist/providers/sse.js +45 -17
- package/dist/providers/tool-input.js +17 -0
- package/dist/sessions/catalog.js +199 -0
- package/dist/sessions/codec.js +2 -1
- package/dist/sessions/lease.js +7 -0
- package/dist/sessions/runtime.js +11 -3
- package/dist/sessions/store.js +171 -78
- package/dist/settings.js +10 -5
- package/dist/start.js +12 -2
- package/dist/text-boundary.js +2 -0
- package/dist/tools/search.js +27 -18
- package/dist/tui/app-input.js +40 -5
- package/dist/tui/app-state.js +1 -0
- package/dist/tui/app-workflows.js +1 -0
- package/dist/tui/app.js +11 -3
- package/dist/tui/editor.js +2 -0
- package/dist/tui/keys.js +64 -5
- package/dist/tui/overlay.js +12 -4
- package/dist/tui/picker.js +2 -0
- package/dist/tui/screen.js +5 -17
- package/dist/user-store.js +54 -0
- package/package.json +7 -3
- /package/{docs/assets/brand → assets}/jeco-256.png +0 -0
package/dist/sessions/store.js
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
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
6
|
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
-
import { chmod, lstat, mkdir, open, opendir, readFile, readdir, realpath, rename, rm,
|
|
7
|
+
import { chmod, lstat, mkdir, open, opendir, readFile, readdir, realpath, rename, rm, } from "node:fs/promises";
|
|
8
8
|
import * as path from "node:path";
|
|
9
9
|
import { atomicWrite } from "../atomic.js";
|
|
10
10
|
import { CONVERSATION_LIMITS, ConversationTree } from "../conversation.js";
|
|
11
|
-
import { leadingText } from "../text-boundary.js";
|
|
12
11
|
import { userDataPath } from "../user-data.js";
|
|
12
|
+
import { advanceSessionCatalog, catalogMatches, decodeSessionCatalog, encodeSessionCatalog, sameSessionHead, sessionCatalog, SESSION_CATALOG_BYTES, SESSION_CATALOG_FILE, SESSION_CHECKPOINT_FILE, } from "./catalog.js";
|
|
13
13
|
import { decodeHead, decodeMeta, decodeNode, encodeHead, encodeMeta, encodeNode, SESSION_FILE_LIMITS, SESSION_SCHEMA, } from "./codec.js";
|
|
14
14
|
import { leaseOwner, leaseToken, pidIsAlive, removeLease, sessionLease, } from "./lease.js";
|
|
15
15
|
const DIRECTORY_MODE = 0o700;
|
|
@@ -44,24 +44,7 @@ export class DurableSessionStore {
|
|
|
44
44
|
for (let start = 0; start < names.length; start += CATALOG_READ_CONCURRENCY) {
|
|
45
45
|
const batch = await Promise.all(names.slice(start, start + CATALOG_READ_CONCURRENCY)
|
|
46
46
|
.map(async (id) => {
|
|
47
|
-
|
|
48
|
-
const snapshot = await this.load(id);
|
|
49
|
-
const conversation = snapshot.conversation.latestResumable();
|
|
50
|
-
if (conversation === undefined)
|
|
51
|
-
return undefined;
|
|
52
|
-
return {
|
|
53
|
-
id,
|
|
54
|
-
createdAt: snapshot.meta.createdAt,
|
|
55
|
-
updatedAt: snapshot.head.updatedAt,
|
|
56
|
-
turns: selectedTurnCount(conversation),
|
|
57
|
-
preview: firstUserText(conversation),
|
|
58
|
-
active: await this.#leaseIsActive(id),
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
catch {
|
|
62
|
-
// Corrupt or foreign data never becomes a resume candidate.
|
|
63
|
-
return undefined;
|
|
64
|
-
}
|
|
47
|
+
return await this.#catalogEntry(id);
|
|
65
48
|
}));
|
|
66
49
|
catalog.push(...batch.filter((entry) => entry !== undefined));
|
|
67
50
|
}
|
|
@@ -74,9 +57,7 @@ export class DurableSessionStore {
|
|
|
74
57
|
const directory = this.#sessionDirectory(id);
|
|
75
58
|
await assertDirectory(directory);
|
|
76
59
|
const meta = decodeMeta(await readJson(path.join(directory, "meta.json"), SESSION_FILE_LIMITS.metadataBytes));
|
|
77
|
-
|
|
78
|
-
workspaceKey(meta.workspaceRoot) !== workspaceKey(this.workspaceRoot))
|
|
79
|
-
throw new Error("session belongs to a different workspace");
|
|
60
|
+
assertSessionWorkspace(meta, id, this.workspaceRoot, this.workspaceDigest);
|
|
80
61
|
let head = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes));
|
|
81
62
|
const stored = await readNodes(path.join(directory, "nodes"));
|
|
82
63
|
const ahead = stored.filter((entry) => entry.sequence > head.sequence);
|
|
@@ -105,19 +86,23 @@ export class DurableSessionStore {
|
|
|
105
86
|
if (!replacesHead && !extendsTree) {
|
|
106
87
|
throw new Error("session checkpoint cannot be recovered safely");
|
|
107
88
|
}
|
|
108
|
-
head = {
|
|
89
|
+
head = Object.freeze({
|
|
109
90
|
version: SESSION_SCHEMA,
|
|
110
91
|
sequence: candidate.sequence,
|
|
111
92
|
nodeId: candidate.node.id,
|
|
112
93
|
parentId: candidate.node.parentId,
|
|
113
94
|
revision: candidate.node.revision,
|
|
114
95
|
updatedAt: candidate.updatedAt,
|
|
115
|
-
};
|
|
116
|
-
await atomicWrite(path.join(directory, "head.json"), encodeHead(head), {
|
|
96
|
+
});
|
|
97
|
+
await atomicWrite(path.join(directory, "head.json"), encodeHead(head), {
|
|
98
|
+
mode: FILE_MODE,
|
|
99
|
+
validate: async () => assertDirectory(directory),
|
|
100
|
+
});
|
|
117
101
|
}
|
|
118
102
|
const nodes = stored.map((entry) => entry.node);
|
|
119
103
|
const conversation = ConversationTree.restore(nodes, head.nodeId);
|
|
120
|
-
|
|
104
|
+
const catalog = sessionCatalog(meta, head, conversation);
|
|
105
|
+
return Object.freeze({ meta, head, conversation, catalog });
|
|
121
106
|
}
|
|
122
107
|
async publish(conversation, claim) {
|
|
123
108
|
const active = conversation.activeNode;
|
|
@@ -129,21 +114,22 @@ export class DurableSessionStore {
|
|
|
129
114
|
const token = claim === true ? leaseToken() : undefined;
|
|
130
115
|
const temporary = path.join(this.#bucket, `.${id}.${randomUUID()}.tmp`);
|
|
131
116
|
const target = this.#sessionDirectory(id);
|
|
132
|
-
const meta = {
|
|
117
|
+
const meta = Object.freeze({
|
|
133
118
|
version: SESSION_SCHEMA,
|
|
134
119
|
id,
|
|
135
120
|
workspaceRoot: this.workspaceRoot,
|
|
136
121
|
workspaceDigest: this.workspaceDigest,
|
|
137
122
|
createdAt: now,
|
|
138
|
-
};
|
|
139
|
-
const head = {
|
|
123
|
+
});
|
|
124
|
+
const head = Object.freeze({
|
|
140
125
|
version: SESSION_SCHEMA,
|
|
141
126
|
sequence: conversation.nodes.length,
|
|
142
127
|
nodeId: active.id,
|
|
143
128
|
parentId: active.parentId,
|
|
144
129
|
revision: active.revision,
|
|
145
130
|
updatedAt: now,
|
|
146
|
-
};
|
|
131
|
+
});
|
|
132
|
+
const catalog = sessionCatalog(meta, head, conversation);
|
|
147
133
|
try {
|
|
148
134
|
await makePrivateDirectory(temporary);
|
|
149
135
|
const nodes = path.join(temporary, "nodes");
|
|
@@ -154,6 +140,7 @@ export class DurableSessionStore {
|
|
|
154
140
|
}
|
|
155
141
|
await atomicWrite(path.join(temporary, "meta.json"), encodeMeta(meta), { mode: FILE_MODE });
|
|
156
142
|
await atomicWrite(path.join(temporary, "head.json"), encodeHead(head), { mode: FILE_MODE });
|
|
143
|
+
await atomicWrite(path.join(temporary, SESSION_CATALOG_FILE), encodeSessionCatalog(catalog), { mode: FILE_MODE });
|
|
157
144
|
if (token !== undefined) {
|
|
158
145
|
await atomicWrite(path.join(temporary, "active"), token, { mode: FILE_MODE });
|
|
159
146
|
}
|
|
@@ -163,18 +150,32 @@ export class DurableSessionStore {
|
|
|
163
150
|
await removeTemporaryDirectory(temporary, this.#bucket);
|
|
164
151
|
throw error;
|
|
165
152
|
}
|
|
166
|
-
const snapshot = Object.freeze({ meta, head, conversation });
|
|
153
|
+
const snapshot = Object.freeze({ meta, head, conversation, catalog });
|
|
167
154
|
if (token === undefined)
|
|
168
155
|
return snapshot;
|
|
169
156
|
const lease = sessionLease(id, path.join(target, "active"), token);
|
|
170
157
|
return Object.freeze({ ...snapshot, lease });
|
|
171
158
|
}
|
|
172
|
-
async checkpoint(
|
|
173
|
-
|
|
159
|
+
async checkpoint(previous, conversation) {
|
|
160
|
+
assertSnapshot(previous, this.workspaceRoot, this.workspaceDigest);
|
|
161
|
+
const id = previous.meta.id;
|
|
162
|
+
const directory = this.#sessionDirectory(id);
|
|
163
|
+
await assertDirectory(directory);
|
|
164
|
+
const nodesDirectory = path.join(directory, "nodes");
|
|
165
|
+
const validateNodesDirectory = async () => {
|
|
166
|
+
await assertDirectory(directory);
|
|
167
|
+
await assertDirectory(nodesDirectory);
|
|
168
|
+
};
|
|
169
|
+
await validateNodesDirectory();
|
|
170
|
+
const currentHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes));
|
|
171
|
+
if (!sameSessionHead(currentHead, previous.head)) {
|
|
172
|
+
throw new Error("session head changed after its verified snapshot");
|
|
173
|
+
}
|
|
174
174
|
const active = conversation.activeNode;
|
|
175
175
|
if (active === undefined)
|
|
176
176
|
throw new Error("an empty conversation cannot be checkpointed");
|
|
177
|
-
if (
|
|
177
|
+
if (active === previous.conversation.activeNode &&
|
|
178
|
+
conversation.nodes === previous.conversation.nodes) {
|
|
178
179
|
return Object.freeze({ ...previous, conversation });
|
|
179
180
|
}
|
|
180
181
|
const replacesHead = active.id === previous.head.nodeId &&
|
|
@@ -187,19 +188,31 @@ export class DurableSessionStore {
|
|
|
187
188
|
throw new Error("session checkpoint does not extend its durable tree");
|
|
188
189
|
}
|
|
189
190
|
assertSharedNodes(previous.conversation, conversation, replacesHead ? active.id : undefined);
|
|
191
|
+
if (extendsTree) {
|
|
192
|
+
await assertMissingNode(path.join(nodesDirectory, nodeName(active.id)));
|
|
193
|
+
}
|
|
190
194
|
const now = new Date().toISOString();
|
|
191
|
-
const head = {
|
|
195
|
+
const head = Object.freeze({
|
|
192
196
|
version: SESSION_SCHEMA,
|
|
193
197
|
sequence: previous.head.sequence + 1,
|
|
194
198
|
nodeId: active.id,
|
|
195
199
|
parentId: active.parentId,
|
|
196
200
|
revision: active.revision,
|
|
197
201
|
updatedAt: now,
|
|
198
|
-
};
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
await atomicWrite(path.join(directory,
|
|
202
|
-
|
|
202
|
+
});
|
|
203
|
+
const catalog = advanceSessionCatalog(previous.catalog, previous.meta, head, conversation);
|
|
204
|
+
const checkpointToken = leaseToken();
|
|
205
|
+
await atomicWrite(path.join(directory, SESSION_CHECKPOINT_FILE), checkpointToken, { mode: FILE_MODE, validate: async () => assertDirectory(directory) });
|
|
206
|
+
await atomicWrite(path.join(nodesDirectory, nodeName(active.id)), encodeNode(active, head.sequence, now), { mode: FILE_MODE, validate: async () => validateNodesDirectory() });
|
|
207
|
+
await atomicWrite(path.join(directory, "head.json"), encodeHead(head), {
|
|
208
|
+
mode: FILE_MODE,
|
|
209
|
+
validate: async () => assertDirectory(directory),
|
|
210
|
+
});
|
|
211
|
+
await this.#writeCatalog(previous.meta.id, catalog, checkpointToken).catch(() => undefined);
|
|
212
|
+
// Once the canonical head is durable, a missing summary is detectable by
|
|
213
|
+
// its head mismatch and can be rebuilt without retaining a live marker.
|
|
214
|
+
await removeLease(path.join(directory, SESSION_CHECKPOINT_FILE), checkpointToken);
|
|
215
|
+
return Object.freeze({ meta: previous.meta, head, conversation, catalog });
|
|
203
216
|
}
|
|
204
217
|
async claim(id) {
|
|
205
218
|
assertSessionId(id);
|
|
@@ -243,6 +256,74 @@ export class DurableSessionStore {
|
|
|
243
256
|
const owner = await leaseOwner(path.join(this.#sessionDirectory(id), "active"));
|
|
244
257
|
return owner !== undefined && pidIsAlive(owner.pid);
|
|
245
258
|
}
|
|
259
|
+
async #catalogEntry(id) {
|
|
260
|
+
try {
|
|
261
|
+
const directory = this.#sessionDirectory(id);
|
|
262
|
+
await assertDirectory(directory);
|
|
263
|
+
const checkpointFile = path.join(directory, SESSION_CHECKPOINT_FILE);
|
|
264
|
+
// A second head read closes the only useful race: a checkpoint landing
|
|
265
|
+
// between the small record reads. A changing marker gets one retry.
|
|
266
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
267
|
+
const checkpointBefore = await leaseOwner(checkpointFile);
|
|
268
|
+
try {
|
|
269
|
+
const [metaValue, headValue, catalogValue] = await Promise.all([
|
|
270
|
+
readJson(path.join(directory, "meta.json"), SESSION_FILE_LIMITS.metadataBytes),
|
|
271
|
+
readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes),
|
|
272
|
+
readJson(path.join(directory, SESSION_CATALOG_FILE), SESSION_CATALOG_BYTES),
|
|
273
|
+
]);
|
|
274
|
+
const meta = decodeMeta(metaValue);
|
|
275
|
+
const head = decodeHead(headValue);
|
|
276
|
+
const storedCatalog = decodeSessionCatalog(catalogValue);
|
|
277
|
+
assertSessionWorkspace(meta, id, this.workspaceRoot, this.workspaceDigest);
|
|
278
|
+
const confirmedHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes));
|
|
279
|
+
const checkpointAfter = await leaseOwner(checkpointFile);
|
|
280
|
+
if (!sameLease(checkpointBefore, checkpointAfter))
|
|
281
|
+
continue;
|
|
282
|
+
if (!sameSessionHead(head, confirmedHead) ||
|
|
283
|
+
!catalogMatches(storedCatalog, meta, head))
|
|
284
|
+
break;
|
|
285
|
+
if (checkpointAfter !== undefined && !pidIsAlive(checkpointAfter.pid))
|
|
286
|
+
break;
|
|
287
|
+
const active = await this.#leaseIsActive(id) || checkpointAfter !== undefined;
|
|
288
|
+
return catalogEntry(storedCatalog, active);
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// Missing, stale, or malformed summaries are rebuilt only while the
|
|
295
|
+
// session is idle. Selecting a session still performs this strict load.
|
|
296
|
+
const checkpoint = await leaseOwner(checkpointFile);
|
|
297
|
+
if (await this.#leaseIsActive(id) ||
|
|
298
|
+
(checkpoint !== undefined && pidIsAlive(checkpoint.pid)))
|
|
299
|
+
return undefined;
|
|
300
|
+
const snapshot = await this.load(id);
|
|
301
|
+
await this.#writeCatalog(id, snapshot.catalog, checkpoint?.token).catch(() => undefined);
|
|
302
|
+
const currentCheckpoint = await leaseOwner(checkpointFile);
|
|
303
|
+
const active = await this.#leaseIsActive(id) ||
|
|
304
|
+
(currentCheckpoint !== undefined && pidIsAlive(currentCheckpoint.pid));
|
|
305
|
+
return catalogEntry(snapshot.catalog, active);
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
// Corrupt, unsafe, active-without-a-summary, or foreign data never
|
|
309
|
+
// becomes a resume candidate.
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
async #writeCatalog(id, catalog, checkpointToken) {
|
|
314
|
+
const directory = this.#sessionDirectory(id);
|
|
315
|
+
const validate = async () => {
|
|
316
|
+
await assertDirectory(directory);
|
|
317
|
+
const currentHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes));
|
|
318
|
+
if (!sameSessionHead(currentHead, catalog.head)) {
|
|
319
|
+
throw new Error("session head changed while updating its catalogue");
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
await atomicWrite(path.join(directory, SESSION_CATALOG_FILE), encodeSessionCatalog(catalog), { mode: FILE_MODE, validate });
|
|
323
|
+
if (checkpointToken !== undefined) {
|
|
324
|
+
await removeLease(path.join(directory, SESSION_CHECKPOINT_FILE), checkpointToken);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
246
327
|
}
|
|
247
328
|
async function catalogNames(directory) {
|
|
248
329
|
try {
|
|
@@ -266,22 +347,65 @@ async function catalogNames(directory) {
|
|
|
266
347
|
}
|
|
267
348
|
return names.sort((left, right) => right.localeCompare(left));
|
|
268
349
|
}
|
|
350
|
+
function catalogEntry(catalog, active) {
|
|
351
|
+
if (catalog.resumeNodeId === 0)
|
|
352
|
+
return undefined;
|
|
353
|
+
return Object.freeze({
|
|
354
|
+
id: catalog.id,
|
|
355
|
+
createdAt: catalog.createdAt,
|
|
356
|
+
updatedAt: catalog.head.updatedAt,
|
|
357
|
+
turns: catalog.turns,
|
|
358
|
+
preview: catalog.preview,
|
|
359
|
+
active,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
function sameLease(left, right) {
|
|
363
|
+
return left?.token === right?.token;
|
|
364
|
+
}
|
|
365
|
+
function assertSessionWorkspace(meta, id, workspaceRoot, workspaceDigest) {
|
|
366
|
+
if (meta.id !== id || meta.workspaceDigest !== workspaceDigest ||
|
|
367
|
+
workspaceKey(meta.workspaceRoot) !== workspaceKey(workspaceRoot))
|
|
368
|
+
throw new Error("session belongs to a different workspace");
|
|
369
|
+
}
|
|
269
370
|
function assertSharedNodes(previous, next, replacedId) {
|
|
270
371
|
for (const node of previous.nodes) {
|
|
271
372
|
if (node.id === replacedId)
|
|
272
373
|
continue;
|
|
273
374
|
const candidate = next.node(node.id);
|
|
274
|
-
if (candidate
|
|
375
|
+
if (candidate !== node) {
|
|
275
376
|
throw new Error("session checkpoint rewrites prior conversation history");
|
|
276
377
|
}
|
|
277
378
|
}
|
|
278
379
|
}
|
|
279
|
-
function
|
|
280
|
-
|
|
281
|
-
|
|
380
|
+
function assertSnapshot(snapshot, workspaceRoot, workspaceDigest) {
|
|
381
|
+
encodeMeta(snapshot.meta);
|
|
382
|
+
encodeHead(snapshot.head);
|
|
383
|
+
encodeSessionCatalog(snapshot.catalog);
|
|
384
|
+
assertSessionId(snapshot.meta.id);
|
|
385
|
+
if (snapshot.meta.workspaceDigest !== workspaceDigest ||
|
|
386
|
+
workspaceKey(snapshot.meta.workspaceRoot) !== workspaceKey(workspaceRoot))
|
|
387
|
+
throw new Error("session snapshot belongs to a different workspace");
|
|
388
|
+
const active = snapshot.conversation.activeNode;
|
|
389
|
+
if (active === undefined ||
|
|
390
|
+
active.id !== snapshot.head.nodeId ||
|
|
391
|
+
active.parentId !== snapshot.head.parentId ||
|
|
392
|
+
active.revision !== snapshot.head.revision ||
|
|
393
|
+
snapshot.head.sequence < snapshot.conversation.nodes.length)
|
|
394
|
+
throw new Error("session snapshot does not match its verified head");
|
|
395
|
+
if (!catalogMatches(snapshot.catalog, snapshot.meta, snapshot.head)) {
|
|
396
|
+
throw new Error("session snapshot does not match its verified catalogue");
|
|
397
|
+
}
|
|
282
398
|
}
|
|
283
|
-
function
|
|
284
|
-
|
|
399
|
+
async function assertMissingNode(file) {
|
|
400
|
+
try {
|
|
401
|
+
await lstat(file);
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
if (error.code === "ENOENT")
|
|
405
|
+
return;
|
|
406
|
+
throw error;
|
|
407
|
+
}
|
|
408
|
+
throw new Error("session has an incomplete node outside its verified snapshot");
|
|
285
409
|
}
|
|
286
410
|
async function readNodes(directory) {
|
|
287
411
|
await assertDirectory(directory);
|
|
@@ -344,17 +468,6 @@ async function makePrivateDirectory(directory) {
|
|
|
344
468
|
if (process.platform !== "win32")
|
|
345
469
|
await chmod(directory, DIRECTORY_MODE);
|
|
346
470
|
}
|
|
347
|
-
async function directoryEntries(directory) {
|
|
348
|
-
try {
|
|
349
|
-
await assertDirectory(directory);
|
|
350
|
-
return await readdir(directory, { withFileTypes: true });
|
|
351
|
-
}
|
|
352
|
-
catch (error) {
|
|
353
|
-
if (error.code === "ENOENT")
|
|
354
|
-
return [];
|
|
355
|
-
throw error;
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
471
|
async function removeTemporaryDirectory(directory, bucket) {
|
|
359
472
|
const relative = path.relative(bucket, directory);
|
|
360
473
|
if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative) ||
|
|
@@ -362,26 +475,6 @@ async function removeTemporaryDirectory(directory, bucket) {
|
|
|
362
475
|
throw new Error("refusing to remove an unverified session directory");
|
|
363
476
|
await rm(directory, { recursive: true, force: true });
|
|
364
477
|
}
|
|
365
|
-
function selectedTurnCount(conversation) {
|
|
366
|
-
let count = 0;
|
|
367
|
-
let id = conversation.activeNodeId;
|
|
368
|
-
while (id !== 0) {
|
|
369
|
-
count++;
|
|
370
|
-
id = conversation.node(id)?.parentId ?? 0;
|
|
371
|
-
}
|
|
372
|
-
return count;
|
|
373
|
-
}
|
|
374
|
-
function firstUserText(conversation) {
|
|
375
|
-
for (const message of conversation.history) {
|
|
376
|
-
if (message.role !== "user")
|
|
377
|
-
continue;
|
|
378
|
-
const text = message.content.find((block) => block.kind === "text")?.text
|
|
379
|
-
.replace(/\s+/gu, " ").trim();
|
|
380
|
-
if (text !== undefined && text !== "")
|
|
381
|
-
return leadingText(text, 160);
|
|
382
|
-
}
|
|
383
|
-
return "Untitled session";
|
|
384
|
-
}
|
|
385
478
|
function nodeName(id) {
|
|
386
479
|
return `${String(id).padStart(6, "0")}.json`;
|
|
387
480
|
}
|
package/dist/settings.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
// Persistent, non-secret defaults for interactive and batch sessions.
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
2
|
import { chmod, mkdir } from "node:fs/promises";
|
|
4
3
|
import * as path from "node:path";
|
|
5
4
|
import { atomicWrite } from "./atomic.js";
|
|
@@ -9,6 +8,7 @@ import { providerNames } from "./providers/index.js";
|
|
|
9
8
|
import { parseOllamaEndpoint } from "./providers/ollama-endpoint.js";
|
|
10
9
|
import { withStoreLock } from "./store-lock.js";
|
|
11
10
|
import { userDataLabel, userDataPath } from "./user-data.js";
|
|
11
|
+
import { assertStoreText, readBoundedJsonSync, USER_STORE_LIMITS } from "./user-store.js";
|
|
12
12
|
export { EFFORTS } from "./effort.js";
|
|
13
13
|
let saved;
|
|
14
14
|
export function readSettings() {
|
|
@@ -24,7 +24,9 @@ export async function updateSettings(patch) {
|
|
|
24
24
|
await chmod(directory, 0o700);
|
|
25
25
|
return withStoreLock(file, async () => {
|
|
26
26
|
const next = normalize({ ...readStore(file), ...patch });
|
|
27
|
-
|
|
27
|
+
const text = `${JSON.stringify(next, null, 2)}\n`;
|
|
28
|
+
assertStoreText(text, USER_STORE_LIMITS.settingsBytes);
|
|
29
|
+
await atomicWrite(file, text, { mode: 0o600 });
|
|
28
30
|
saved = next;
|
|
29
31
|
return file;
|
|
30
32
|
});
|
|
@@ -41,7 +43,7 @@ export function reloadSettings() {
|
|
|
41
43
|
}
|
|
42
44
|
function readStore(file = settingsPath()) {
|
|
43
45
|
try {
|
|
44
|
-
return normalize(
|
|
46
|
+
return normalize(readBoundedJsonSync(file, USER_STORE_LIMITS.settingsBytes));
|
|
45
47
|
}
|
|
46
48
|
catch {
|
|
47
49
|
// Missing, unreadable, and malformed stores all fall back safely. A bad
|
|
@@ -73,14 +75,14 @@ function normalize(value) {
|
|
|
73
75
|
function modelsOf(value, providers) {
|
|
74
76
|
if (!record(value))
|
|
75
77
|
return undefined;
|
|
76
|
-
const models = Object.fromEntries(Object.entries(value).filter((entry) => providers.includes(entry[0]) &&
|
|
78
|
+
const models = Object.fromEntries(Object.entries(value).filter((entry) => providers.includes(entry[0]) && boundedNonempty(entry[1], USER_STORE_LIMITS.model)));
|
|
77
79
|
return Object.keys(models).length === 0 ? undefined : models;
|
|
78
80
|
}
|
|
79
81
|
function member(value, values) {
|
|
80
82
|
return typeof value === "string" && values.includes(value) ? value : undefined;
|
|
81
83
|
}
|
|
82
84
|
function endpoint(value) {
|
|
83
|
-
if (
|
|
85
|
+
if (!boundedNonempty(value, USER_STORE_LIMITS.endpoint))
|
|
84
86
|
return undefined;
|
|
85
87
|
try {
|
|
86
88
|
return parseOllamaEndpoint(value).baseUrl;
|
|
@@ -89,6 +91,9 @@ function endpoint(value) {
|
|
|
89
91
|
return undefined;
|
|
90
92
|
}
|
|
91
93
|
}
|
|
94
|
+
function boundedNonempty(value, max) {
|
|
95
|
+
return typeof value === "string" && value.length <= max && value.trim() !== "";
|
|
96
|
+
}
|
|
92
97
|
function positiveInteger(value) {
|
|
93
98
|
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
|
|
94
99
|
}
|
package/dist/start.js
CHANGED
|
@@ -76,14 +76,24 @@ export async function start(args = process.argv.slice(2), environment = {}) {
|
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
try {
|
|
79
|
-
|
|
79
|
+
if (environment.runInteractive === undefined) {
|
|
80
|
+
await runApp(session, transcriptRoot, { shutdownSignal: environment.signal });
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
await environment.runInteractive(session, transcriptRoot, environment.signal);
|
|
84
|
+
}
|
|
80
85
|
}
|
|
81
86
|
finally {
|
|
82
87
|
await session.persistence?.close();
|
|
83
88
|
}
|
|
84
89
|
}
|
|
85
90
|
else {
|
|
86
|
-
|
|
91
|
+
if (environment.runNonInteractive === undefined) {
|
|
92
|
+
await runBatch(session, { signal: environment.signal });
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
await environment.runNonInteractive(session, environment.signal);
|
|
96
|
+
}
|
|
87
97
|
}
|
|
88
98
|
}
|
|
89
99
|
function applyResumedSession(session, conversation, persistence) {
|
package/dist/text-boundary.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Shared grapheme boundaries for every projection of user-visible text.
|
|
2
2
|
const SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
3
|
+
/** Shared persisted-text and user-prompt boundary, measured like String.length. */
|
|
4
|
+
export const MAX_TEXT_CODE_UNITS = 1_048_576;
|
|
3
5
|
export function segmentGraphemes(text) {
|
|
4
6
|
return SEGMENTER.segment(text);
|
|
5
7
|
}
|
package/dist/tools/search.js
CHANGED
|
@@ -232,11 +232,10 @@ function preferRipgrep(files, bytes) {
|
|
|
232
232
|
return files.length >= MIN_RG_TAIL_FILES || bytes >= MIN_RG_TAIL_BYTES;
|
|
233
233
|
}
|
|
234
234
|
async function walk(start, ctx, visit) {
|
|
235
|
-
const pending = [
|
|
235
|
+
const pending = [];
|
|
236
236
|
let seen = 0;
|
|
237
|
-
|
|
237
|
+
const enter = async (lexical) => {
|
|
238
238
|
checkAbort(ctx.signal);
|
|
239
|
-
const lexical = pending.pop();
|
|
240
239
|
const directory = await resolveExistingInRoot(ctx.root, lexical);
|
|
241
240
|
let entries;
|
|
242
241
|
try {
|
|
@@ -244,24 +243,34 @@ async function walk(start, ctx, visit) {
|
|
|
244
243
|
}
|
|
245
244
|
catch (error) {
|
|
246
245
|
if (skippable(error))
|
|
247
|
-
|
|
246
|
+
return;
|
|
248
247
|
throw error;
|
|
249
248
|
}
|
|
250
249
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
250
|
+
pending.push({ directory, entries, next: 0 });
|
|
251
|
+
};
|
|
252
|
+
await enter(start);
|
|
253
|
+
while (pending.length > 0) {
|
|
254
|
+
checkAbort(ctx.signal);
|
|
255
|
+
const frame = pending[pending.length - 1];
|
|
256
|
+
if (frame === undefined)
|
|
257
|
+
break;
|
|
258
|
+
const entry = frame.entries[frame.next++];
|
|
259
|
+
if (entry === undefined) {
|
|
260
|
+
pending.pop();
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (++seen > MAX_VISITED)
|
|
264
|
+
return { capped: true };
|
|
265
|
+
if (entry.isSymbolicLink())
|
|
266
|
+
continue;
|
|
267
|
+
const target = path.join(frame.directory, entry.name);
|
|
268
|
+
if (entry.isDirectory()) {
|
|
269
|
+
if (!SKIP.has(entry.name))
|
|
270
|
+
await enter(target);
|
|
271
|
+
}
|
|
272
|
+
else if (entry.isFile() && (await visit(target))) {
|
|
273
|
+
return { capped: false };
|
|
265
274
|
}
|
|
266
275
|
}
|
|
267
276
|
return { capped: false };
|
package/dist/tui/app-input.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Keyboard and pointer intent for the TUI shell.
|
|
2
|
+
import { PROMPT_LIMIT_MESSAGE, PromptLimitError } from "../input-boundary.js";
|
|
2
3
|
import { activate as activateCompletion, move as moveCompletion, selected as selectedCompletion, } from "./complete.js";
|
|
3
4
|
import * as edit from "./editor.js";
|
|
4
5
|
import { turnBlocker } from "./feedback.js";
|
|
@@ -21,6 +22,13 @@ export function appInput(options) {
|
|
|
21
22
|
}
|
|
22
23
|
if (state.feedback !== undefined)
|
|
23
24
|
feedback.dismiss();
|
|
25
|
+
if (key.name === "input_limit") {
|
|
26
|
+
if (state.open === undefined)
|
|
27
|
+
rejectPrompt();
|
|
28
|
+
else
|
|
29
|
+
showInputLimit();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
24
32
|
// Detail expansion remains available while an approval is open. A large
|
|
25
33
|
// diff may be compacted, but the user must be able to inspect it before
|
|
26
34
|
// answering the permission prompt.
|
|
@@ -33,6 +41,8 @@ export function appInput(options) {
|
|
|
33
41
|
if (state.open !== undefined) {
|
|
34
42
|
const outcome = overlay.handle(state.open, key);
|
|
35
43
|
state.open = outcome.open;
|
|
44
|
+
if (outcome.inputLimit === true)
|
|
45
|
+
showInputLimit();
|
|
36
46
|
if (outcome.abort === true)
|
|
37
47
|
state.activity?.control.abort(new Error("interrupted"));
|
|
38
48
|
if (outcome.quit === true)
|
|
@@ -61,8 +71,10 @@ export function appInput(options) {
|
|
|
61
71
|
case "enter": {
|
|
62
72
|
if (state.completing !== undefined) {
|
|
63
73
|
const completed = selectedCompletion(state.completing);
|
|
64
|
-
if (completed !== undefined)
|
|
74
|
+
if (completed !== undefined) {
|
|
65
75
|
state.editor = edit.of(completed);
|
|
76
|
+
state.promptRejected = false;
|
|
77
|
+
}
|
|
66
78
|
state.completing = undefined;
|
|
67
79
|
}
|
|
68
80
|
submit();
|
|
@@ -75,6 +87,7 @@ export function appInput(options) {
|
|
|
75
87
|
const completed = completion === undefined ? undefined : selectedCompletion(completion);
|
|
76
88
|
if (completed !== undefined) {
|
|
77
89
|
state.editor = edit.of(completed);
|
|
90
|
+
state.promptRejected = false;
|
|
78
91
|
state.completing = undefined;
|
|
79
92
|
}
|
|
80
93
|
return;
|
|
@@ -106,16 +119,26 @@ export function appInput(options) {
|
|
|
106
119
|
options.invalidate();
|
|
107
120
|
return;
|
|
108
121
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
122
|
+
try {
|
|
123
|
+
const edited = applyKey(state.editor, key);
|
|
124
|
+
if (edited !== undefined) {
|
|
125
|
+
if (edited.text !== state.editor.text)
|
|
126
|
+
state.promptRejected = false;
|
|
127
|
+
state.editor = edited;
|
|
128
|
+
state.completing = state.activity === undefined ? activateCompletion(edited.text) : undefined;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (!(error instanceof PromptLimitError))
|
|
133
|
+
throw error;
|
|
134
|
+
rejectPrompt();
|
|
113
135
|
}
|
|
114
136
|
}
|
|
115
137
|
function recall(step) {
|
|
116
138
|
if (state.past.length === 0)
|
|
117
139
|
return;
|
|
118
140
|
state.completing = undefined;
|
|
141
|
+
state.promptRejected = false;
|
|
119
142
|
if (state.recall === -1) {
|
|
120
143
|
if (step > 0)
|
|
121
144
|
return;
|
|
@@ -132,6 +155,10 @@ export function appInput(options) {
|
|
|
132
155
|
state.editor = edit.of(state.past[state.recall]);
|
|
133
156
|
}
|
|
134
157
|
function submit() {
|
|
158
|
+
if (state.promptRejected) {
|
|
159
|
+
keep(PROMPT_LIMIT_MESSAGE);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
135
162
|
const text = state.editor.text.trim();
|
|
136
163
|
if (text === "")
|
|
137
164
|
return;
|
|
@@ -171,6 +198,7 @@ export function appInput(options) {
|
|
|
171
198
|
state.recall = -1;
|
|
172
199
|
state.draft = "";
|
|
173
200
|
state.completing = undefined;
|
|
201
|
+
state.promptRejected = false;
|
|
174
202
|
state.past.push(text);
|
|
175
203
|
state.scroll = 0;
|
|
176
204
|
state.follow = true;
|
|
@@ -179,5 +207,12 @@ export function appInput(options) {
|
|
|
179
207
|
function keep(text) {
|
|
180
208
|
feedback.show({ text: `${text} · prompt kept`, tone: "warn", timeoutMs: 4_000 });
|
|
181
209
|
}
|
|
210
|
+
function rejectPrompt() {
|
|
211
|
+
state.promptRejected = true;
|
|
212
|
+
keep(PROMPT_LIMIT_MESSAGE);
|
|
213
|
+
}
|
|
214
|
+
function showInputLimit() {
|
|
215
|
+
feedback.show({ text: PROMPT_LIMIT_MESSAGE, tone: "warn", timeoutMs: 4_000 });
|
|
216
|
+
}
|
|
182
217
|
return { handle };
|
|
183
218
|
}
|
package/dist/tui/app-state.js
CHANGED
|
@@ -212,6 +212,7 @@ export function appWorkflows(options) {
|
|
|
212
212
|
nodeId: nodeId ?? prospectiveNodeId,
|
|
213
213
|
coveredMessages: context?.messageCount ?? 0,
|
|
214
214
|
lastInputTokens: Math.max(session.usage.lastInputTokens, request.inputTokens),
|
|
215
|
+
estimatedInputTokens: request.inputTokens,
|
|
215
216
|
signal: activity.control.signal,
|
|
216
217
|
force,
|
|
217
218
|
policy: request.policy,
|