@giovannijecha/jecode 0.7.1 → 0.7.2
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 +14 -10
- package/dist/context/manual.js +1 -0
- package/dist/conversation.js +41 -4
- 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/tui/app-workflows.js +54 -5
- package/dist/tui/app.js +31 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -168,7 +168,7 @@ Type `/` to open searchable command completion inside the composer.
|
|
|
168
168
|
| `/providers` | Manage provider connections, API keys, ChatGPT sign-in, and Ollama endpoints |
|
|
169
169
|
| `/models` | Search all currently usable provider catalogues and select a model |
|
|
170
170
|
| `/permissions` | Change session tool access and review remembered approvals |
|
|
171
|
-
| `/timeline` | Browse
|
|
171
|
+
| `/timeline` | Browse resumable turns and select where the next branch should begin |
|
|
172
172
|
| `/compact` | Compact the current branch context without deleting saved conversation history |
|
|
173
173
|
| `/new` | Start a new conversation and reset session tool permissions |
|
|
174
174
|
| `/export` | Save a timestamped Markdown transcript in the launch directory |
|
|
@@ -203,13 +203,16 @@ the resume picker until it has a settled turn. Resuming and continuing a
|
|
|
203
203
|
conversation keeps its durable session identity and updates one picker entry
|
|
204
204
|
instead of creating duplicates.
|
|
205
205
|
|
|
206
|
-
`/timeline` shows completed turns in the conversation
|
|
207
|
-
turn changes only the in-memory path: it creates and
|
|
208
|
-
the next real user message. Cancelling the picker or
|
|
209
|
-
leaves the durable head unchanged.
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
206
|
+
`/timeline` shows completed, failed, and interrupted turns in the conversation
|
|
207
|
+
tree. Selecting an older turn changes only the in-memory path: it creates and
|
|
208
|
+
saves a branch only after the next real user message. Cancelling the picker or
|
|
209
|
+
exiting before that message leaves the durable head unchanged. A failed turn
|
|
210
|
+
keeps the same partial evidence and outcome in the live transcript, export, and
|
|
211
|
+
resume, while the next model receives a neutral failure boundary instead of
|
|
212
|
+
incomplete streamed text. Historical tools are displayed but never executed.
|
|
213
|
+
If a process stops abruptly inside a tool loop, Jecode resumes from the latest
|
|
214
|
+
safe ancestor and lets the next user turn create a branch. `/export` writes
|
|
215
|
+
only the currently selected path.
|
|
213
216
|
|
|
214
217
|
When model-facing context approaches the selected model's usable capacity,
|
|
215
218
|
Jecode asks the provider for a bounded summary of the older prefix and keeps
|
|
@@ -289,8 +292,9 @@ untrusted data.
|
|
|
289
292
|
- Provider handshakes and idle response bodies have finite deadlines. Only
|
|
290
293
|
idempotent catalogue reads retry; generation requests are never replayed.
|
|
291
294
|
- Model, terminal, and filesystem input are bounded before use.
|
|
292
|
-
- Session files are versioned, size-bounded
|
|
293
|
-
treated as untrusted when loaded. A
|
|
295
|
+
- Session files are versioned, symmetrically size-bounded before write and
|
|
296
|
+
after read, atomically checkpointed, and treated as untrusted when loaded. A
|
|
297
|
+
live lease prevents concurrent resume.
|
|
294
298
|
|
|
295
299
|
`run_command` is not an operating-system sandbox. An approved command can still
|
|
296
300
|
access files and account resources available to the current user. Review
|
package/dist/context/manual.js
CHANGED
|
@@ -62,6 +62,7 @@ export async function compactSession(session, options = {}) {
|
|
|
62
62
|
messages: active.messages,
|
|
63
63
|
blocks: active.blocks,
|
|
64
64
|
context: result.anchor,
|
|
65
|
+
...(active.failure === undefined ? {} : { failure: active.failure }),
|
|
65
66
|
}, active.settlement);
|
|
66
67
|
await session.persistence?.checkpoint(next);
|
|
67
68
|
session.conversation = next;
|
package/dist/conversation.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// project an older prefix before it is sent to a provider. Provider traffic
|
|
6
6
|
// and live screen blocks remain prospective until a consistent checkpoint.
|
|
7
7
|
import { projectContext, validContextAnchor } from "./context/projection.js";
|
|
8
|
+
import { assertPersistableNode } from "./sessions/codec.js";
|
|
8
9
|
export const CONVERSATION_LIMITS = Object.freeze({
|
|
9
10
|
nodes: 1_024,
|
|
10
11
|
messageCodeUnits: 8_388_608,
|
|
@@ -37,6 +38,7 @@ export class ConversationTree {
|
|
|
37
38
|
messages: node.messages,
|
|
38
39
|
blocks: node.blocks,
|
|
39
40
|
...(node.context === undefined ? {} : { context: node.context }),
|
|
41
|
+
...(node.failure === undefined ? {} : { failure: node.failure }),
|
|
40
42
|
}, node.settlement);
|
|
41
43
|
const restored = tree.activeNode;
|
|
42
44
|
if (restored === undefined || restored.id !== node.id) {
|
|
@@ -92,6 +94,19 @@ export class ConversationTree {
|
|
|
92
94
|
}
|
|
93
95
|
return undefined;
|
|
94
96
|
}
|
|
97
|
+
/** Select the newest turn that is safe to continue after a restart. */
|
|
98
|
+
latestResumable() {
|
|
99
|
+
let id = this.#activeNodeId;
|
|
100
|
+
while (id !== 0) {
|
|
101
|
+
const node = this.node(id);
|
|
102
|
+
if (node === undefined)
|
|
103
|
+
throw new Error("conversation path is incomplete");
|
|
104
|
+
if (node.settlement !== "checkpointed")
|
|
105
|
+
return this.select(id);
|
|
106
|
+
id = node.parentId;
|
|
107
|
+
}
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
95
110
|
get history() {
|
|
96
111
|
return this.#path().flatMap((node) => clone(node.messages));
|
|
97
112
|
}
|
|
@@ -99,7 +114,12 @@ export class ConversationTree {
|
|
|
99
114
|
return projectContext(this.#path());
|
|
100
115
|
}
|
|
101
116
|
get transcript() {
|
|
102
|
-
return this.#path().flatMap((node) =>
|
|
117
|
+
return this.#path().flatMap((node) => [
|
|
118
|
+
...clone(node.blocks),
|
|
119
|
+
...(node.failure === undefined
|
|
120
|
+
? []
|
|
121
|
+
: [{ kind: "notice", text: node.failure.text, tone: node.failure.tone }]),
|
|
122
|
+
]);
|
|
103
123
|
}
|
|
104
124
|
#append(draft, settlement) {
|
|
105
125
|
if (this.#nodes.length >= CONVERSATION_LIMITS.nodes) {
|
|
@@ -115,8 +135,10 @@ export class ConversationTree {
|
|
|
115
135
|
messages: draft.messages,
|
|
116
136
|
blocks: settledBlocks(draft.blocks),
|
|
117
137
|
...(draft.context === undefined ? {} : { context: draft.context }),
|
|
138
|
+
...(draft.failure === undefined ? {} : { failure: draft.failure }),
|
|
118
139
|
});
|
|
119
140
|
assertTurn(node);
|
|
141
|
+
assertPersistableNode(node);
|
|
120
142
|
const nodes = [...this.#nodes, node];
|
|
121
143
|
assertBounds(nodes);
|
|
122
144
|
return new ConversationTree(nodes, node.id);
|
|
@@ -135,8 +157,10 @@ export class ConversationTree {
|
|
|
135
157
|
messages: draft.messages,
|
|
136
158
|
blocks: settledBlocks(draft.blocks),
|
|
137
159
|
context: draft.context ?? current.context,
|
|
160
|
+
failure: draft.failure,
|
|
138
161
|
});
|
|
139
162
|
assertTurn(node);
|
|
163
|
+
assertPersistableNode(node);
|
|
140
164
|
const nodes = [...this.#nodes];
|
|
141
165
|
nodes[id - 1] = node;
|
|
142
166
|
assertBounds(nodes);
|
|
@@ -163,6 +187,7 @@ function ownedNode(node) {
|
|
|
163
187
|
messages: Object.freeze(clone(node.messages)),
|
|
164
188
|
blocks: Object.freeze(clone(node.blocks)),
|
|
165
189
|
...(node.context === undefined ? {} : { context: Object.freeze({ ...node.context }) }),
|
|
190
|
+
...(node.failure === undefined ? {} : { failure: Object.freeze({ ...node.failure }) }),
|
|
166
191
|
});
|
|
167
192
|
}
|
|
168
193
|
function settledBlocks(blocks) {
|
|
@@ -174,6 +199,8 @@ function settledBlocks(blocks) {
|
|
|
174
199
|
return [settled];
|
|
175
200
|
}
|
|
176
201
|
if (block.kind === "tool") {
|
|
202
|
+
if (block.tone === "pending")
|
|
203
|
+
return [];
|
|
177
204
|
const { startedAt: _startedAt, expanded: _expanded, ...settled } = block;
|
|
178
205
|
return [settled];
|
|
179
206
|
}
|
|
@@ -185,16 +212,26 @@ function assertTurn(node) {
|
|
|
185
212
|
!validNodeId(node.parentId) || node.parentId >= node.id ||
|
|
186
213
|
!Number.isSafeInteger(node.revision) || node.revision < 1 ||
|
|
187
214
|
node.createdAt.length === 0 || node.createdAt.length > 64 ||
|
|
188
|
-
(node.settlement
|
|
215
|
+
!validSettlement(node.settlement) ||
|
|
189
216
|
node.messages.length < 2 || node.messages[0]?.role !== "user" ||
|
|
190
217
|
node.identity.providerId.length === 0 || node.identity.providerId.length > 128 ||
|
|
191
218
|
node.identity.model.length === 0 || node.identity.model.length > 512 ||
|
|
192
219
|
node.identity.effort.length === 0 || node.identity.effort.length > 32)
|
|
193
220
|
throw new Error("turn checkpoint is invalid");
|
|
194
|
-
if (node.settlement
|
|
195
|
-
throw new Error("a
|
|
221
|
+
if (node.settlement !== "checkpointed" && node.messages.at(-1)?.role !== "assistant") {
|
|
222
|
+
throw new Error("a resumable turn must end with an assistant message");
|
|
223
|
+
}
|
|
224
|
+
const failed = node.settlement === "failed" || node.settlement === "interrupted";
|
|
225
|
+
if (failed !== (node.failure !== undefined) ||
|
|
226
|
+
(node.settlement === "failed" && node.failure?.tone !== "error") ||
|
|
227
|
+
(node.settlement === "interrupted" && node.failure?.tone !== "warn")) {
|
|
228
|
+
throw new Error("turn failure state is invalid");
|
|
196
229
|
}
|
|
197
230
|
}
|
|
231
|
+
function validSettlement(value) {
|
|
232
|
+
return value === "checkpointed" || value === "completed" ||
|
|
233
|
+
value === "failed" || value === "interrupted";
|
|
234
|
+
}
|
|
198
235
|
function assertBounds(nodes) {
|
|
199
236
|
let messageCodeUnits = 0;
|
|
200
237
|
let transcriptCodeUnits = 0;
|
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")
|
|
@@ -10,6 +10,7 @@ import { saveTranscript } from "../transcript-export.js";
|
|
|
10
10
|
import { recordAuxiliaryUsage, recordUsage } from "../usage.js";
|
|
11
11
|
import { selectTimeline } from "../timeline.js";
|
|
12
12
|
import { answerAt } from "./approve.js";
|
|
13
|
+
import * as edit from "./editor.js";
|
|
13
14
|
import { cancel as cancelOpen } from "./overlay.js";
|
|
14
15
|
import { controllerOptions, turnFailure } from "./session-view.js";
|
|
15
16
|
import { transcribe } from "./turn.js";
|
|
@@ -136,7 +137,7 @@ export function appWorkflows(options) {
|
|
|
136
137
|
},
|
|
137
138
|
usage: (usage) => recordUsage(session.usage, usage),
|
|
138
139
|
});
|
|
139
|
-
const persist = async (checkpoint, settlement) => {
|
|
140
|
+
const persist = async (checkpoint, settlement, failure) => {
|
|
140
141
|
const next = session.conversation.commit({
|
|
141
142
|
...(nodeId === undefined ? {} : { nodeId }),
|
|
142
143
|
parentId,
|
|
@@ -149,6 +150,7 @@ export function appWorkflows(options) {
|
|
|
149
150
|
messages: checkpoint.slice(historyStart),
|
|
150
151
|
blocks: state.blocks.slice(blockStart),
|
|
151
152
|
...(context === undefined ? {} : { context }),
|
|
153
|
+
...(failure === undefined ? {} : { failure }),
|
|
152
154
|
}, settlement);
|
|
153
155
|
await session.persistence?.checkpoint(next);
|
|
154
156
|
session.conversation = next;
|
|
@@ -217,18 +219,65 @@ export function appWorkflows(options) {
|
|
|
217
219
|
return compacted;
|
|
218
220
|
};
|
|
219
221
|
let finishReason;
|
|
222
|
+
let failed;
|
|
220
223
|
try {
|
|
221
224
|
await runTurn(history, controllerOptions(session, permissions.availableTools()), events, activity.control.signal, modelHistory);
|
|
222
225
|
}
|
|
223
226
|
catch (error) {
|
|
224
227
|
const interrupted = activity.control.signal.aborted;
|
|
225
|
-
|
|
226
|
-
|
|
228
|
+
const completed = nodeId !== undefined && session.conversation.activeNodeId === nodeId &&
|
|
229
|
+
session.conversation.activeNode?.settlement === "completed";
|
|
230
|
+
if (completed) {
|
|
231
|
+
const notice = turnFailure(session, error, interrupted);
|
|
232
|
+
feedback.show({ text: notice.text, tone: notice.tone, timeoutMs: 6_000 });
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
finishReason = interrupted ? "interrupted" : "failed";
|
|
236
|
+
failed = { error: error, interrupted };
|
|
237
|
+
}
|
|
227
238
|
}
|
|
228
239
|
finally {
|
|
229
|
-
|
|
230
|
-
|
|
240
|
+
try {
|
|
241
|
+
events.finish(finishReason);
|
|
242
|
+
if (failed !== undefined) {
|
|
243
|
+
const notice = turnFailure(session, failed.error, failed.interrupted);
|
|
244
|
+
const settlement = failed.interrupted ? "interrupted" : "failed";
|
|
245
|
+
const failure = {
|
|
246
|
+
text: notice.text,
|
|
247
|
+
tone: failed.interrupted ? "warn" : "error",
|
|
248
|
+
};
|
|
249
|
+
options.emit(notice);
|
|
250
|
+
try {
|
|
251
|
+
await persist(closeFailedTurn(history, settlement), settlement, failure);
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
// A failed persistence boundary cannot remain visible as if it had
|
|
255
|
+
// been saved. Revert to the last durable path and return the input
|
|
256
|
+
// to the composer so the user can retry without losing it.
|
|
257
|
+
options.replaceTranscript();
|
|
258
|
+
state.editor = edit.of(text);
|
|
259
|
+
feedback.show({
|
|
260
|
+
text: error.message,
|
|
261
|
+
tone: "error",
|
|
262
|
+
timeoutMs: 6_000,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
finally {
|
|
268
|
+
options.finishActivity(activity);
|
|
269
|
+
}
|
|
231
270
|
}
|
|
232
271
|
}
|
|
233
272
|
return { command, turn };
|
|
234
273
|
}
|
|
274
|
+
function closeFailedTurn(history, settlement) {
|
|
275
|
+
const closed = [...history];
|
|
276
|
+
if (closed.at(-1)?.role === "assistant")
|
|
277
|
+
return closed;
|
|
278
|
+
const text = settlement === "interrupted"
|
|
279
|
+
? "The previous attempt was interrupted by the user before completion."
|
|
280
|
+
: "The previous attempt failed before completion.";
|
|
281
|
+
closed.push({ role: "assistant", content: [{ kind: "text", text }] });
|
|
282
|
+
return closed;
|
|
283
|
+
}
|
package/dist/tui/app.js
CHANGED
|
@@ -41,6 +41,7 @@ export async function runApp(session, transcriptRoot, environment = {}) {
|
|
|
41
41
|
let stopResize = () => { };
|
|
42
42
|
let stopInput = () => { };
|
|
43
43
|
let failure;
|
|
44
|
+
let activeWorkflow;
|
|
44
45
|
// Timers outlive the teardown they were scheduled before. Painting after the
|
|
45
46
|
// terminal has been handed back would write escapes into the user's shell.
|
|
46
47
|
let live = true;
|
|
@@ -141,22 +142,42 @@ export async function runApp(session, transcriptRoot, environment = {}) {
|
|
|
141
142
|
if (!live)
|
|
142
143
|
return;
|
|
143
144
|
live = false;
|
|
144
|
-
stopInput
|
|
145
|
-
stopResize
|
|
145
|
+
safely(stopInput);
|
|
146
|
+
safely(stopResize);
|
|
146
147
|
if (spinTimer !== undefined)
|
|
147
148
|
clearInterval(spinTimer);
|
|
148
149
|
if (frameTimer !== undefined)
|
|
149
150
|
clearTimeout(frameTimer);
|
|
150
151
|
if (escapeTimer !== undefined)
|
|
151
152
|
clearTimeout(escapeTimer);
|
|
152
|
-
feedback.close();
|
|
153
|
-
terminal.leave();
|
|
153
|
+
safely(() => feedback.close());
|
|
154
|
+
safely(() => terminal.leave());
|
|
154
155
|
closed?.();
|
|
155
156
|
}
|
|
157
|
+
function safely(action) {
|
|
158
|
+
try {
|
|
159
|
+
action();
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
failure ??= { error };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
156
165
|
function fail(error) {
|
|
157
166
|
failure ??= { error };
|
|
167
|
+
state.open = overlay.cancel(state.open);
|
|
168
|
+
state.activity?.control.abort(error);
|
|
158
169
|
quit();
|
|
159
170
|
}
|
|
171
|
+
function track(work) {
|
|
172
|
+
const tracked = work
|
|
173
|
+
.catch((error) => fail(error))
|
|
174
|
+
.finally(() => {
|
|
175
|
+
if (activeWorkflow === tracked)
|
|
176
|
+
activeWorkflow = undefined;
|
|
177
|
+
});
|
|
178
|
+
activeWorkflow = tracked;
|
|
179
|
+
return tracked;
|
|
180
|
+
}
|
|
160
181
|
function requestQuit() {
|
|
161
182
|
const activity = state.activity;
|
|
162
183
|
if (activity === undefined) {
|
|
@@ -226,7 +247,10 @@ export async function runApp(session, transcriptRoot, environment = {}) {
|
|
|
226
247
|
session,
|
|
227
248
|
state,
|
|
228
249
|
feedback,
|
|
229
|
-
actions
|
|
250
|
+
actions: {
|
|
251
|
+
command: (text) => track(actions.command(text)),
|
|
252
|
+
turn: (text) => track(actions.turn(text)),
|
|
253
|
+
},
|
|
230
254
|
live: () => live,
|
|
231
255
|
quit,
|
|
232
256
|
requestQuit,
|
|
@@ -236,7 +260,7 @@ export async function runApp(session, transcriptRoot, environment = {}) {
|
|
|
236
260
|
});
|
|
237
261
|
const resumeAtLaunch = session.resume === undefined
|
|
238
262
|
? undefined
|
|
239
|
-
: openResumedSession(session.resume);
|
|
263
|
+
: track(openResumedSession(session.resume));
|
|
240
264
|
async function openResumedSession(launch) {
|
|
241
265
|
while (live) {
|
|
242
266
|
const index = await new Promise((resolve) => {
|
|
@@ -305,6 +329,7 @@ export async function runApp(session, transcriptRoot, environment = {}) {
|
|
|
305
329
|
finally {
|
|
306
330
|
try {
|
|
307
331
|
quit();
|
|
332
|
+
await activeWorkflow;
|
|
308
333
|
}
|
|
309
334
|
finally {
|
|
310
335
|
await session.persistence?.close();
|