@giovannijecha/jecode 0.7.1 → 0.7.3
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 +20 -15
- package/dist/batch.js +22 -18
- package/dist/cli-info.js +1 -1
- package/dist/config.js +3 -2
- package/dist/context/budget.js +37 -0
- package/dist/context/compactor.js +9 -2
- package/dist/context/estimate.js +31 -0
- package/dist/context/manual.js +1 -0
- package/dist/context/policy.js +11 -8
- package/dist/controller-request.js +26 -3
- package/dist/controller.js +1 -0
- package/dist/conversation.js +89 -35
- package/dist/providers/anthropic.js +1 -1
- package/dist/providers/http.js +4 -2
- package/dist/providers/ollama.js +1 -1
- package/dist/providers/openai-codex.js +1 -1
- package/dist/providers/openai.js +1 -1
- package/dist/providers/sse.js +3 -3
- package/dist/providers/stream-limits.js +14 -2
- package/dist/sessions/codec.js +54 -8
- package/dist/sessions/runtime.js +2 -2
- package/dist/sessions/store.js +54 -32
- package/dist/timeline.js +12 -6
- package/dist/tools/fs.js +80 -38
- package/dist/tools/search.js +106 -44
- package/dist/tools/shell.js +14 -6
- package/dist/tools/text-boundary.js +37 -25
- package/dist/tui/app-state.js +0 -1
- package/dist/tui/app-workflows.js +92 -34
- package/dist/tui/app.js +43 -20
- package/dist/tui/blocks.js +0 -2
- package/dist/tui/components/tool.js +4 -8
- package/dist/tui/session-view.js +2 -1
- package/dist/tui/transcript-view.js +178 -106
- package/dist/tui/view.js +8 -3
- package/package.json +2 -1
package/dist/providers/http.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// event stream. Only idempotent reads retry. Once a POST starts or response
|
|
3
3
|
// bytes flow, a failure is surfaced rather than silently replayed.
|
|
4
4
|
import { readSseJson } from "./sse.js";
|
|
5
|
+
import { sseStreamCharacterLimit } from "./stream-limits.js";
|
|
5
6
|
const RETRYABLE = new Set([408, 409, 429, 500, 502, 503, 504]);
|
|
6
7
|
const MAX_JSON_CHARS = 5_000_000;
|
|
7
8
|
const MAX_ERROR_CHARS = 2_000;
|
|
@@ -33,11 +34,12 @@ async function asJson(url, res) {
|
|
|
33
34
|
throw httpError(`${url} returned non-JSON`, res.status, text.slice(0, 500));
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
|
-
export async function postSse(url, headers, body, signal, onStatus) {
|
|
37
|
+
export async function postSse(url, headers, body, maxOutputTokens, signal, onStatus) {
|
|
38
|
+
const maximumChars = sseStreamCharacterLimit(maxOutputTokens);
|
|
37
39
|
const res = await request(url, { accept: "text/event-stream", ...headers }, body, signal, onStatus);
|
|
38
40
|
if (res.body === null)
|
|
39
41
|
throw httpError(`${url} returned no body`, res.status);
|
|
40
|
-
return readSseJson(withIdleTimeout(url, res.body));
|
|
42
|
+
return readSseJson(withIdleTimeout(url, res.body), maximumChars);
|
|
41
43
|
}
|
|
42
44
|
async function request(url, headers, body, signal, onStatus) {
|
|
43
45
|
const maxRetries = body === undefined ? GET_RETRIES : 0;
|
package/dist/providers/ollama.js
CHANGED
|
@@ -88,7 +88,7 @@ export const ollama = {
|
|
|
88
88
|
max_tokens: req.maxTokens,
|
|
89
89
|
reasoning_effort: effort,
|
|
90
90
|
stream: true,
|
|
91
|
-
}, req.signal, req.onStatus);
|
|
91
|
+
}, req.maxTokens, req.signal, req.onStatus);
|
|
92
92
|
const reply = await assembleOllama(events, req.onStream);
|
|
93
93
|
const notice = stopNotice(reply);
|
|
94
94
|
if (notice !== undefined)
|
|
@@ -71,7 +71,7 @@ export const openaiCodex = {
|
|
|
71
71
|
text: { verbosity: "low" },
|
|
72
72
|
include: ["reasoning.encrypted_content"],
|
|
73
73
|
prompt_cache_key: SESSION_ID,
|
|
74
|
-
}, req.signal, req.onStatus);
|
|
74
|
+
}, req.maxTokens, req.signal, req.onStatus);
|
|
75
75
|
const data = await assembleOpenAI(events, req.onStream);
|
|
76
76
|
const notice = stopNotice(data);
|
|
77
77
|
if (notice !== undefined)
|
package/dist/providers/openai.js
CHANGED
|
@@ -86,7 +86,7 @@ export const openai = {
|
|
|
86
86
|
store: false,
|
|
87
87
|
include: ["reasoning.encrypted_content"],
|
|
88
88
|
stream: true,
|
|
89
|
-
}, req.signal, req.onStatus);
|
|
89
|
+
}, req.maxTokens, req.signal, req.onStatus);
|
|
90
90
|
const data = await assembleOpenAI(events, req.onStream);
|
|
91
91
|
const notice = stopNotice(data);
|
|
92
92
|
if (notice !== undefined)
|
package/dist/providers/sse.js
CHANGED
|
@@ -3,15 +3,15 @@
|
|
|
3
3
|
// The format is small: `field: value` lines, a blank line ends an event. Only
|
|
4
4
|
// `data` matters here — both providers put the event discriminator inside the
|
|
5
5
|
// JSON payload, so the `event:` line is redundant and skipped.
|
|
6
|
-
import { addBounded, MAX_SSE_EVENT_CHARS,
|
|
7
|
-
export async function* readSseJson(body) {
|
|
6
|
+
import { addBounded, MAX_SSE_EVENT_CHARS, } from "./stream-limits.js";
|
|
7
|
+
export async function* readSseJson(body, maximumChars) {
|
|
8
8
|
const reader = body.getReader();
|
|
9
9
|
const decoder = new TextDecoder();
|
|
10
10
|
let buffer = "";
|
|
11
11
|
let finished = false;
|
|
12
12
|
let total = 0;
|
|
13
13
|
const append = (text) => {
|
|
14
|
-
total = addBounded(total, text.length,
|
|
14
|
+
total = addBounded(total, text.length, maximumChars, "SSE stream");
|
|
15
15
|
buffer += text;
|
|
16
16
|
};
|
|
17
17
|
try {
|
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
// Response streams are remote input. Bound the pieces that otherwise grow
|
|
2
|
-
// independently
|
|
2
|
+
// independently, while letting a larger requested output carry its necessarily
|
|
3
|
+
// larger framing, terminal envelope, and opaque reasoning payloads.
|
|
3
4
|
export const MAX_SSE_EVENT_CHARS = 1_000_000;
|
|
4
|
-
export const MAX_SSE_STREAM_CHARS =
|
|
5
|
+
export const MAX_SSE_STREAM_CHARS = 256_000_000;
|
|
5
6
|
export const MAX_TOOL_ARGUMENT_CHARS = 1_000_000;
|
|
7
|
+
const MIN_SSE_STREAM_CHARS = 4_000_000;
|
|
8
|
+
const SSE_CHARS_PER_OUTPUT_TOKEN = 512;
|
|
9
|
+
export function sseStreamCharacterLimit(maxOutputTokens) {
|
|
10
|
+
if (!Number.isSafeInteger(maxOutputTokens) || maxOutputTokens <= 0) {
|
|
11
|
+
throw new Error("max output tokens must be a positive safe integer");
|
|
12
|
+
}
|
|
13
|
+
const scaled = maxOutputTokens > Math.floor(MAX_SSE_STREAM_CHARS / SSE_CHARS_PER_OUTPUT_TOKEN)
|
|
14
|
+
? MAX_SSE_STREAM_CHARS
|
|
15
|
+
: maxOutputTokens * SSE_CHARS_PER_OUTPUT_TOKEN;
|
|
16
|
+
return Math.max(MIN_SSE_STREAM_CHARS, scaled);
|
|
17
|
+
}
|
|
6
18
|
export function addBounded(total, added, maximum, label) {
|
|
7
19
|
if (added > maximum - total) {
|
|
8
20
|
throw new Error(`${label} exceeded ${maximum} characters`);
|
package/dist/sessions/codec.js
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
// Strict codecs for session files. Disk is an untrusted boundary even when
|
|
2
2
|
// the directory is owner-only: every value is bounded and re-owned before it
|
|
3
3
|
// can become conversation or provider input.
|
|
4
|
+
import { Buffer } from "node:buffer";
|
|
4
5
|
import { CONTEXT_LIMITS } from "../context/projection.js";
|
|
5
|
-
export const SESSION_SCHEMA =
|
|
6
|
+
export const SESSION_SCHEMA = 3;
|
|
6
7
|
export const SESSION_FILE_LIMITS = Object.freeze({
|
|
7
8
|
text: 1_048_576,
|
|
9
|
+
metadataBytes: 64 * 1_024,
|
|
10
|
+
nodeBytes: 20 * 1_024 * 1_024,
|
|
8
11
|
jsonDepth: 24,
|
|
9
12
|
jsonNodes: 32_768,
|
|
10
13
|
blocks: 8_192,
|
|
11
14
|
details: 8_192,
|
|
12
15
|
});
|
|
13
16
|
export function encodeMeta(meta) {
|
|
14
|
-
|
|
17
|
+
decodeMeta(meta);
|
|
18
|
+
return boundedLine(meta, SESSION_FILE_LIMITS.metadataBytes);
|
|
15
19
|
}
|
|
16
20
|
export function decodeMeta(value) {
|
|
17
21
|
if (!record(value) || !keys(value, "createdAt,id,version,workspaceDigest,workspaceRoot")) {
|
|
@@ -32,7 +36,8 @@ export function decodeMeta(value) {
|
|
|
32
36
|
});
|
|
33
37
|
}
|
|
34
38
|
export function encodeHead(head) {
|
|
35
|
-
|
|
39
|
+
decodeHead(head);
|
|
40
|
+
return boundedLine(head, SESSION_FILE_LIMITS.metadataBytes);
|
|
36
41
|
}
|
|
37
42
|
export function decodeHead(value) {
|
|
38
43
|
if (!record(value) || !keys(value, "nodeId,parentId,revision,sequence,updatedAt,version")) {
|
|
@@ -55,7 +60,16 @@ export function decodeHead(value) {
|
|
|
55
60
|
});
|
|
56
61
|
}
|
|
57
62
|
export function encodeNode(node, sequence, updatedAt) {
|
|
58
|
-
|
|
63
|
+
const envelope = nodeEnvelope(node, sequence, updatedAt);
|
|
64
|
+
decodeNode(envelope);
|
|
65
|
+
return boundedLine(envelope, SESSION_FILE_LIMITS.nodeBytes);
|
|
66
|
+
}
|
|
67
|
+
/** Enforce the exact current-disk boundary before a turn enters the tree. */
|
|
68
|
+
export function assertPersistableNode(node) {
|
|
69
|
+
encodeNode(node, 1, node.createdAt);
|
|
70
|
+
}
|
|
71
|
+
function nodeEnvelope(node, sequence, updatedAt) {
|
|
72
|
+
return {
|
|
59
73
|
version: SESSION_SCHEMA,
|
|
60
74
|
sequence,
|
|
61
75
|
updatedAt,
|
|
@@ -69,8 +83,9 @@ export function encodeNode(node, sequence, updatedAt) {
|
|
|
69
83
|
messages: node.messages.map(messageRecord),
|
|
70
84
|
blocks: node.blocks.flatMap(blockRecord),
|
|
71
85
|
context: node.context ?? null,
|
|
86
|
+
failure: node.failure ?? null,
|
|
72
87
|
},
|
|
73
|
-
}
|
|
88
|
+
};
|
|
74
89
|
}
|
|
75
90
|
export function decodeNode(value) {
|
|
76
91
|
if (!record(value) || !keys(value, "node,sequence,updatedAt,version"))
|
|
@@ -82,7 +97,9 @@ export function decodeNode(value) {
|
|
|
82
97
|
const raw = value["node"];
|
|
83
98
|
const nodeKeys = version === 1
|
|
84
99
|
? "blocks,createdAt,id,identity,messages,parentId,revision,settlement"
|
|
85
|
-
:
|
|
100
|
+
: version === 2
|
|
101
|
+
? "blocks,context,createdAt,id,identity,messages,parentId,revision,settlement"
|
|
102
|
+
: "blocks,context,createdAt,failure,id,identity,messages,parentId,revision,settlement";
|
|
86
103
|
if (!record(raw) || !keys(raw, nodeKeys)) {
|
|
87
104
|
throw invalid();
|
|
88
105
|
}
|
|
@@ -93,7 +110,7 @@ export function decodeNode(value) {
|
|
|
93
110
|
!integer(raw["parentId"], 0) ||
|
|
94
111
|
!integer(raw["revision"], 1) ||
|
|
95
112
|
!timestamp(raw["createdAt"]) ||
|
|
96
|
-
(raw["settlement"]
|
|
113
|
+
!settlement(raw["settlement"], version) ||
|
|
97
114
|
!record(identity) || !keys(identity, "effort,model,providerId") ||
|
|
98
115
|
!bounded(identity["providerId"], 128) || !bounded(identity["model"], 512) ||
|
|
99
116
|
!bounded(identity["effort"], 32) ||
|
|
@@ -103,6 +120,9 @@ export function decodeNode(value) {
|
|
|
103
120
|
const context = version === 1
|
|
104
121
|
? undefined
|
|
105
122
|
: contextFromRecord(raw["context"], raw["id"], messages.length);
|
|
123
|
+
const failure = version < 3
|
|
124
|
+
? undefined
|
|
125
|
+
: failureFromRecord(raw["failure"], raw["settlement"]);
|
|
106
126
|
const node = Object.freeze({
|
|
107
127
|
id: raw["id"],
|
|
108
128
|
parentId: raw["parentId"],
|
|
@@ -117,9 +137,25 @@ export function decodeNode(value) {
|
|
|
117
137
|
messages: Object.freeze(messages.map(messageFromRecord)),
|
|
118
138
|
blocks: Object.freeze(blocks.map(blockFromRecord)),
|
|
119
139
|
...(context === undefined ? {} : { context: Object.freeze(context) }),
|
|
140
|
+
...(failure === undefined ? {} : { failure: Object.freeze(failure) }),
|
|
120
141
|
});
|
|
121
142
|
return Object.freeze({ sequence: value["sequence"], updatedAt: value["updatedAt"], node });
|
|
122
143
|
}
|
|
144
|
+
function failureFromRecord(value, settlement) {
|
|
145
|
+
const failed = settlement === "failed" || settlement === "interrupted";
|
|
146
|
+
if (value === null) {
|
|
147
|
+
if (failed)
|
|
148
|
+
throw invalid();
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
if (!failed || !record(value) || !keys(value, "text,tone"))
|
|
152
|
+
throw invalid();
|
|
153
|
+
if (!bounded(value["text"]) ||
|
|
154
|
+
(settlement === "failed" && value["tone"] !== "error") ||
|
|
155
|
+
(settlement === "interrupted" && value["tone"] !== "warn"))
|
|
156
|
+
throw invalid();
|
|
157
|
+
return { text: value["text"], tone: value["tone"] };
|
|
158
|
+
}
|
|
123
159
|
function contextFromRecord(value, ownerId, ownerMessages) {
|
|
124
160
|
if (value === null)
|
|
125
161
|
return undefined;
|
|
@@ -340,6 +376,12 @@ function jsonValue(value, budget, depth) {
|
|
|
340
376
|
function line(value) {
|
|
341
377
|
return `${JSON.stringify(value, null, 2)}\n`;
|
|
342
378
|
}
|
|
379
|
+
function boundedLine(value, maxBytes) {
|
|
380
|
+
const encoded = line(value);
|
|
381
|
+
if (Buffer.byteLength(encoded, "utf8") > maxBytes)
|
|
382
|
+
throw invalid();
|
|
383
|
+
return encoded;
|
|
384
|
+
}
|
|
343
385
|
function keys(value, expected) {
|
|
344
386
|
return Object.keys(value).sort().join(",") === expected;
|
|
345
387
|
}
|
|
@@ -356,7 +398,11 @@ function integer(value, minimum) {
|
|
|
356
398
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= minimum;
|
|
357
399
|
}
|
|
358
400
|
function schema(value) {
|
|
359
|
-
return value === 1 || value === SESSION_SCHEMA;
|
|
401
|
+
return value === 1 || value === 2 || value === SESSION_SCHEMA;
|
|
402
|
+
}
|
|
403
|
+
function settlement(value, version) {
|
|
404
|
+
return value === "checkpointed" || value === "completed" ||
|
|
405
|
+
(version >= 3 && (value === "failed" || value === "interrupted"));
|
|
360
406
|
}
|
|
361
407
|
function nullableInteger(value, minimum) {
|
|
362
408
|
return value === null || integer(value, minimum);
|
package/dist/sessions/runtime.js
CHANGED
|
@@ -21,9 +21,9 @@ export class SessionPersistence {
|
|
|
21
21
|
const lease = await store.claim(id);
|
|
22
22
|
try {
|
|
23
23
|
const snapshot = await store.load(id);
|
|
24
|
-
const conversation = snapshot.conversation.
|
|
24
|
+
const conversation = snapshot.conversation.latestResumable();
|
|
25
25
|
if (conversation === undefined)
|
|
26
|
-
throw new Error("session has no
|
|
26
|
+
throw new Error("session has no resumable turn");
|
|
27
27
|
return Object.freeze({
|
|
28
28
|
conversation,
|
|
29
29
|
persistence: new SessionPersistence(store, id, lease),
|
package/dist/sessions/store.js
CHANGED
|
@@ -4,17 +4,17 @@
|
|
|
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, readFile, readdir, realpath, rename, rm, stat, } from "node:fs/promises";
|
|
7
|
+
import { chmod, lstat, mkdir, open, opendir, readFile, readdir, realpath, rename, rm, stat, } 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
11
|
import { userDataPath } from "../user-data.js";
|
|
12
|
-
import { decodeHead, decodeMeta, decodeNode, encodeHead, encodeMeta, encodeNode, SESSION_SCHEMA, } from "./codec.js";
|
|
12
|
+
import { decodeHead, decodeMeta, decodeNode, encodeHead, encodeMeta, encodeNode, SESSION_FILE_LIMITS, SESSION_SCHEMA, } from "./codec.js";
|
|
13
13
|
import { leaseOwner, leaseToken, pidIsAlive, removeLease, sessionLease, } from "./lease.js";
|
|
14
14
|
const DIRECTORY_MODE = 0o700;
|
|
15
15
|
const FILE_MODE = 0o600;
|
|
16
|
-
const
|
|
17
|
-
const
|
|
16
|
+
const MAX_CATALOG_ENTRIES = 4_096;
|
|
17
|
+
const CATALOG_READ_CONCURRENCY = 8;
|
|
18
18
|
const SESSION_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
|
19
19
|
const NODE_NAME = /^(\d{6})\.json$/;
|
|
20
20
|
const ATOMIC_NODE_TEMP = /^\.\d{6}\.json\.\d+\.[a-f0-9-]+\.tmp$/;
|
|
@@ -37,45 +37,45 @@ export class DurableSessionStore {
|
|
|
37
37
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64) {
|
|
38
38
|
throw new Error("session catalogue limit is invalid");
|
|
39
39
|
}
|
|
40
|
-
const
|
|
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);
|
|
40
|
+
const names = await catalogNames(this.#bucket);
|
|
46
41
|
const catalog = [];
|
|
47
|
-
for (
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
42
|
+
for (let start = 0; start < names.length; start += CATALOG_READ_CONCURRENCY) {
|
|
43
|
+
const batch = await Promise.all(names.slice(start, start + CATALOG_READ_CONCURRENCY)
|
|
44
|
+
.map(async (id) => {
|
|
45
|
+
try {
|
|
46
|
+
const snapshot = await this.load(id);
|
|
47
|
+
const conversation = snapshot.conversation.latestResumable();
|
|
48
|
+
if (conversation === undefined)
|
|
49
|
+
return undefined;
|
|
50
|
+
return {
|
|
51
|
+
id,
|
|
52
|
+
createdAt: snapshot.meta.createdAt,
|
|
53
|
+
updatedAt: snapshot.head.updatedAt,
|
|
54
|
+
turns: selectedTurnCount(conversation),
|
|
55
|
+
preview: firstUserText(conversation),
|
|
56
|
+
active: await this.#leaseIsActive(id),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Corrupt or foreign data never becomes a resume candidate.
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}));
|
|
64
|
+
catalog.push(...batch.filter((entry) => entry !== undefined));
|
|
65
65
|
}
|
|
66
66
|
return catalog
|
|
67
|
-
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
|
|
67
|
+
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) || right.id.localeCompare(left.id))
|
|
68
68
|
.slice(0, limit);
|
|
69
69
|
}
|
|
70
70
|
async load(id) {
|
|
71
71
|
assertSessionId(id);
|
|
72
72
|
const directory = this.#sessionDirectory(id);
|
|
73
73
|
await assertDirectory(directory);
|
|
74
|
-
const meta = decodeMeta(await readJson(path.join(directory, "meta.json"),
|
|
74
|
+
const meta = decodeMeta(await readJson(path.join(directory, "meta.json"), SESSION_FILE_LIMITS.metadataBytes));
|
|
75
75
|
if (meta.id !== id || meta.workspaceDigest !== this.workspaceDigest ||
|
|
76
76
|
workspaceKey(meta.workspaceRoot) !== workspaceKey(this.workspaceRoot))
|
|
77
77
|
throw new Error("session belongs to a different workspace");
|
|
78
|
-
let head = decodeHead(await readJson(path.join(directory, "head.json"),
|
|
78
|
+
let head = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes));
|
|
79
79
|
const stored = await readNodes(path.join(directory, "nodes"));
|
|
80
80
|
const ahead = stored.filter((entry) => entry.sequence > head.sequence);
|
|
81
81
|
if (ahead.some((entry) => entry.sequence !== head.sequence + 1) || ahead.length > 1) {
|
|
@@ -242,6 +242,28 @@ export class DurableSessionStore {
|
|
|
242
242
|
return owner !== undefined && pidIsAlive(owner.pid);
|
|
243
243
|
}
|
|
244
244
|
}
|
|
245
|
+
async function catalogNames(directory) {
|
|
246
|
+
try {
|
|
247
|
+
await assertDirectory(directory);
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
if (error.code === "ENOENT")
|
|
251
|
+
return [];
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
const names = [];
|
|
255
|
+
let entries = 0;
|
|
256
|
+
const handle = await opendir(directory);
|
|
257
|
+
for await (const entry of handle) {
|
|
258
|
+
entries++;
|
|
259
|
+
if (entries > MAX_CATALOG_ENTRIES) {
|
|
260
|
+
throw new Error(`session catalogue exceeds ${MAX_CATALOG_ENTRIES} entries`);
|
|
261
|
+
}
|
|
262
|
+
if (entry.isDirectory() && SESSION_NAME.test(entry.name))
|
|
263
|
+
names.push(entry.name);
|
|
264
|
+
}
|
|
265
|
+
return names.sort((left, right) => right.localeCompare(left));
|
|
266
|
+
}
|
|
245
267
|
function assertSharedNodes(previous, next, replacedId) {
|
|
246
268
|
for (const node of previous.nodes) {
|
|
247
269
|
if (node.id === replacedId)
|
|
@@ -278,7 +300,7 @@ async function readNodes(directory) {
|
|
|
278
300
|
const id = Number(NODE_NAME.exec(name)?.[1]);
|
|
279
301
|
if (id !== index + 1)
|
|
280
302
|
throw new Error("session conversation nodes are not contiguous");
|
|
281
|
-
const decoded = decodeNode(await readJson(path.join(directory, name),
|
|
303
|
+
const decoded = decodeNode(await readJson(path.join(directory, name), SESSION_FILE_LIMITS.nodeBytes));
|
|
282
304
|
if (decoded.node.id !== id || sequences.has(decoded.sequence)) {
|
|
283
305
|
throw new Error("session conversation node identity is invalid");
|
|
284
306
|
}
|
package/dist/timeline.js
CHANGED
|
@@ -7,7 +7,7 @@ import { usageFromHistory } from "./usage.js";
|
|
|
7
7
|
import { heading } from "./tui/picker.js";
|
|
8
8
|
export function timelinePicker(conversation, palette) {
|
|
9
9
|
const entries = timelineEntries(conversation);
|
|
10
|
-
const selectedId = conversation.
|
|
10
|
+
const selectedId = conversation.latestResumable()?.activeNodeId ?? 0;
|
|
11
11
|
const index = Math.max(0, entries.findIndex((entry) => entry.node.id === selectedId));
|
|
12
12
|
return Object.freeze({
|
|
13
13
|
picker: {
|
|
@@ -18,7 +18,7 @@ export function timelinePicker(conversation, palette) {
|
|
|
18
18
|
options: entries.map((entry) => ({
|
|
19
19
|
label: `${entry.prefix}${preview(entry.node)}`,
|
|
20
20
|
hint: stamp(entry.node.createdAt),
|
|
21
|
-
...(entry.node
|
|
21
|
+
...timelineValue(entry.node, selectedId),
|
|
22
22
|
})),
|
|
23
23
|
index,
|
|
24
24
|
},
|
|
@@ -38,12 +38,12 @@ export async function selectTimeline(session, choose) {
|
|
|
38
38
|
return true;
|
|
39
39
|
}
|
|
40
40
|
function timelineEntries(conversation) {
|
|
41
|
-
const
|
|
42
|
-
const
|
|
41
|
+
const resumable = conversation.nodes.filter((node) => node.settlement !== "checkpointed");
|
|
42
|
+
const resumableIds = new Set(resumable.map((node) => node.id));
|
|
43
43
|
const children = new Map();
|
|
44
|
-
for (const node of
|
|
44
|
+
for (const node of resumable) {
|
|
45
45
|
let parentId = node.parentId;
|
|
46
|
-
while (parentId !== 0 && !
|
|
46
|
+
while (parentId !== 0 && !resumableIds.has(parentId)) {
|
|
47
47
|
parentId = conversation.node(parentId)?.parentId ?? 0;
|
|
48
48
|
}
|
|
49
49
|
const siblings = children.get(parentId) ?? [];
|
|
@@ -70,6 +70,12 @@ function timelineEntries(conversation) {
|
|
|
70
70
|
visit(0, []);
|
|
71
71
|
return entries;
|
|
72
72
|
}
|
|
73
|
+
function timelineValue(node, selectedId) {
|
|
74
|
+
const state = node.settlement === "completed" ? undefined : node.settlement;
|
|
75
|
+
const active = node.id === selectedId ? "active" : undefined;
|
|
76
|
+
const value = [state, active].filter((part) => part !== undefined).join(" · ");
|
|
77
|
+
return value === "" ? {} : { value };
|
|
78
|
+
}
|
|
73
79
|
function preview(node) {
|
|
74
80
|
for (const message of node.messages) {
|
|
75
81
|
if (message.role !== "user")
|
package/dist/tools/fs.js
CHANGED
|
@@ -4,12 +4,13 @@ import * as fs from "node:fs/promises";
|
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { optionalBool, optionalInt, optionalString, requireString } from "./args.js";
|
|
6
6
|
import { assertDirectWritableInRoot, displayPath, resolveDirectWritableInRoot, resolveExistingInRoot, } from "./paths.js";
|
|
7
|
-
import { assertEditableText, assertReplacementFits, MAX_EDITABLE_CHARS, MAX_EDITABLE_LINES, readEditableText, } from "./text-boundary.js";
|
|
7
|
+
import { assertEditableText, assertReplacementFits, leadingText, MAX_EDITABLE_CHARS, MAX_EDITABLE_LINES, readEditableText, } from "./text-boundary.js";
|
|
8
8
|
import { atomicWrite } from "../atomic.js";
|
|
9
9
|
const MAX_READ_CHARS = 60_000;
|
|
10
10
|
const MAX_LIST_CHARS = 60_000;
|
|
11
11
|
const MAX_LIST_ENTRIES = 2_000;
|
|
12
12
|
const READ_CHUNK_BYTES = 64 * 1024;
|
|
13
|
+
const DEFAULT_MUTATION_DEPENDENCIES = { atomicWrite };
|
|
13
14
|
export const readFile = {
|
|
14
15
|
name: "read_file",
|
|
15
16
|
description: "Read a regular UTF-8 text file inside the workspace. Optionally start at a line " +
|
|
@@ -111,24 +112,35 @@ export const writeFile = {
|
|
|
111
112
|
assertEditableText(content);
|
|
112
113
|
// A write against a file that is already there is a replacement, and the
|
|
113
114
|
// user is owed the difference rather than a wall of green.
|
|
114
|
-
|
|
115
|
+
const before = await current(target);
|
|
116
|
+
return { before: before.text, after: content, beforeExists: before.exists };
|
|
115
117
|
},
|
|
116
118
|
async run(args, ctx) {
|
|
117
|
-
|
|
118
|
-
const target = await resolveDirectWritableInRoot(root, requireString(args, "path"));
|
|
119
|
-
const content = requireString(args, "content", true);
|
|
120
|
-
assertEditableText(content);
|
|
121
|
-
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
122
|
-
const validate = () => assertDirectWritableInRoot(root, target);
|
|
123
|
-
await validate();
|
|
124
|
-
await unchangedSinceApproval(target, ctx.preview?.before);
|
|
125
|
-
await atomicWrite(target, content, { validate });
|
|
126
|
-
return {
|
|
127
|
-
output: `wrote ${displayPath(root, target)} (${content.length} characters)`,
|
|
128
|
-
summary: count(content, "line"),
|
|
129
|
-
};
|
|
119
|
+
return runWriteFile(args, ctx);
|
|
130
120
|
},
|
|
131
121
|
};
|
|
122
|
+
export async function runWriteFile(args, ctx, dependencies = DEFAULT_MUTATION_DEPENDENCIES) {
|
|
123
|
+
const root = await resolveExistingInRoot(ctx.root, ".");
|
|
124
|
+
const target = await resolveDirectWritableInRoot(root, requireString(args, "path"));
|
|
125
|
+
const content = requireString(args, "content", true);
|
|
126
|
+
assertEditableText(content);
|
|
127
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
128
|
+
await assertDirectWritableInRoot(root, target);
|
|
129
|
+
const before = await current(target);
|
|
130
|
+
assertApproved(before, ctx.preview, "write");
|
|
131
|
+
await dependencies.atomicWrite(target, content, {
|
|
132
|
+
async validate(phase) {
|
|
133
|
+
await assertDirectWritableInRoot(root, target);
|
|
134
|
+
if (phase === "before-rename") {
|
|
135
|
+
await assertUnchanged(target, before, "write", ctx.preview !== undefined);
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
return {
|
|
140
|
+
output: `wrote ${displayPath(root, target)} (${content.length} characters)`,
|
|
141
|
+
summary: count(content, "line"),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
132
144
|
export const editFile = {
|
|
133
145
|
name: "edit_file",
|
|
134
146
|
description: "Replace an exact string in a file. The old text must appear exactly once " +
|
|
@@ -150,32 +162,43 @@ export const editFile = {
|
|
|
150
162
|
async preview(args, ctx) {
|
|
151
163
|
const root = await resolveExistingInRoot(ctx.root, ".");
|
|
152
164
|
const target = await resolveDirectWritableInRoot(root, requireString(args, "path"), true);
|
|
153
|
-
const before = await current(target);
|
|
165
|
+
const before = await current(target, true);
|
|
154
166
|
// An edit that will not apply gets no preview: the run is about to say so
|
|
155
167
|
// properly, and a diff of a match that does not exist would be a lie.
|
|
156
168
|
try {
|
|
157
|
-
return {
|
|
169
|
+
return {
|
|
170
|
+
before: before.text,
|
|
171
|
+
after: applied(before.text, args).after,
|
|
172
|
+
beforeExists: true,
|
|
173
|
+
};
|
|
158
174
|
}
|
|
159
175
|
catch {
|
|
160
176
|
return undefined;
|
|
161
177
|
}
|
|
162
178
|
},
|
|
163
179
|
async run(args, ctx) {
|
|
164
|
-
|
|
165
|
-
const target = await resolveDirectWritableInRoot(root, requireString(args, "path"), true);
|
|
166
|
-
const before = await readEditableText(target);
|
|
167
|
-
if (ctx.preview !== undefined && before !== ctx.preview.before) {
|
|
168
|
-
throw new Error("file changed after the preview — inspect it and retry the edit");
|
|
169
|
-
}
|
|
170
|
-
const { after, made } = applied(before, args);
|
|
171
|
-
const validate = () => assertDirectWritableInRoot(root, target, true);
|
|
172
|
-
await atomicWrite(target, after, { validate });
|
|
173
|
-
return {
|
|
174
|
-
output: `edited ${displayPath(root, target)} (${made} replacement${made === 1 ? "" : "s"})`,
|
|
175
|
-
summary: plural(made, "replacement", "replacements"),
|
|
176
|
-
};
|
|
180
|
+
return runEditFile(args, ctx);
|
|
177
181
|
},
|
|
178
182
|
};
|
|
183
|
+
export async function runEditFile(args, ctx, dependencies = DEFAULT_MUTATION_DEPENDENCIES) {
|
|
184
|
+
const root = await resolveExistingInRoot(ctx.root, ".");
|
|
185
|
+
const target = await resolveDirectWritableInRoot(root, requireString(args, "path"), true);
|
|
186
|
+
const before = await current(target, true);
|
|
187
|
+
assertApproved(before, ctx.preview, "edit");
|
|
188
|
+
const { after, made } = applied(before.text, args);
|
|
189
|
+
await dependencies.atomicWrite(target, after, {
|
|
190
|
+
async validate(phase) {
|
|
191
|
+
await assertDirectWritableInRoot(root, target, true);
|
|
192
|
+
if (phase === "before-rename") {
|
|
193
|
+
await assertUnchanged(target, before, "edit", ctx.preview !== undefined);
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
return {
|
|
198
|
+
output: `edited ${displayPath(root, target)} (${made} replacement${made === 1 ? "" : "s"})`,
|
|
199
|
+
summary: plural(made, "replacement", "replacements"),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
179
202
|
async function readRange(target, offset, limit, signal) {
|
|
180
203
|
throwIfAborted(signal);
|
|
181
204
|
const firstLine = Math.max(1, offset ?? 1);
|
|
@@ -203,8 +226,7 @@ async function readRange(target, offset, limit, signal) {
|
|
|
203
226
|
return;
|
|
204
227
|
const room = MAX_READ_CHARS - text.length;
|
|
205
228
|
if (fragment.length > room) {
|
|
206
|
-
|
|
207
|
-
text += fragment.slice(0, room);
|
|
229
|
+
text += leadingText(fragment, room);
|
|
208
230
|
truncated = true;
|
|
209
231
|
stopped = true;
|
|
210
232
|
return;
|
|
@@ -284,9 +306,17 @@ function applied(before, args) {
|
|
|
284
306
|
assertEditableText(after, "edited content");
|
|
285
307
|
return { after, made };
|
|
286
308
|
}
|
|
287
|
-
/** What is on disk now,
|
|
288
|
-
async function current(target) {
|
|
289
|
-
|
|
309
|
+
/** What is on disk now, preserving the difference between absent and empty. */
|
|
310
|
+
async function current(target, mustExist = false) {
|
|
311
|
+
try {
|
|
312
|
+
return { exists: true, text: await readEditableText(target) };
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
if (!mustExist && error.code === "ENOENT") {
|
|
316
|
+
return { exists: false, text: "" };
|
|
317
|
+
}
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
290
320
|
}
|
|
291
321
|
function countOccurrences(haystack, needle) {
|
|
292
322
|
let occurrences = 0;
|
|
@@ -295,12 +325,24 @@ function countOccurrences(haystack, needle) {
|
|
|
295
325
|
}
|
|
296
326
|
return occurrences;
|
|
297
327
|
}
|
|
298
|
-
|
|
328
|
+
function assertApproved(current, preview, operation) {
|
|
329
|
+
if (preview === undefined)
|
|
330
|
+
return;
|
|
331
|
+
const existenceChanged = preview.beforeExists !== undefined && current.exists !== preview.beforeExists;
|
|
332
|
+
if (existenceChanged || current.text !== preview.before) {
|
|
333
|
+
throw changedFile(operation, true);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
async function assertUnchanged(target, expected, operation, previewed) {
|
|
299
337
|
const onDisk = await current(target);
|
|
300
|
-
if (
|
|
301
|
-
throw
|
|
338
|
+
if (onDisk.exists !== expected.exists || onDisk.text !== expected.text) {
|
|
339
|
+
throw changedFile(operation, previewed);
|
|
302
340
|
}
|
|
303
341
|
}
|
|
342
|
+
function changedFile(operation, previewed) {
|
|
343
|
+
const when = previewed ? "after the preview" : "while preparing the change";
|
|
344
|
+
return new Error(`file changed ${when} — inspect it and retry the ${operation}`);
|
|
345
|
+
}
|
|
304
346
|
function count(text, noun) {
|
|
305
347
|
if (text === "")
|
|
306
348
|
return "empty";
|