@giovannijecha/jecode 0.8.5 → 0.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -5
- package/dist/batch-view.js +27 -2
- package/dist/batch.js +2 -0
- package/dist/cli-info.js +0 -1
- package/dist/config.js +14 -6
- package/dist/credential-commands.js +3 -3
- package/dist/openai-account-command.js +14 -12
- package/dist/openai-account.js +7 -5
- package/dist/openai-oauth-callback.js +13 -4
- package/dist/openai-oauth-tokens.js +9 -7
- package/dist/openai-oauth.js +8 -6
- package/dist/permission-command.js +1 -1
- package/dist/provider-commands.js +1 -33
- package/dist/provider-errors.js +1 -10
- package/dist/provider-label.js +3 -10
- package/dist/providers/anthropic.js +0 -1
- package/dist/providers/index.js +1 -5
- package/dist/providers/ollama-context.js +42 -0
- package/dist/providers/ollama-endpoint.js +7 -34
- package/dist/providers/ollama.js +13 -147
- package/dist/providers/openai-codex.js +4 -4
- package/dist/providers/openai.js +0 -1
- package/dist/sessions/bucket.js +55 -0
- package/dist/sessions/catalog-io.js +162 -0
- package/dist/sessions/catalog.js +3 -1
- package/dist/sessions/codec-messages.js +122 -0
- package/dist/sessions/codec-transcript.js +93 -0
- package/dist/sessions/codec-values.js +52 -0
- package/dist/sessions/codec.js +4 -257
- package/dist/sessions/files.js +158 -0
- package/dist/sessions/load.js +90 -0
- package/dist/sessions/snapshot.js +33 -0
- package/dist/sessions/store.js +42 -461
- package/dist/settings-command.js +10 -5
- package/dist/settings.js +16 -15
- package/dist/start.js +1 -2
- package/dist/tools/file-read.js +192 -0
- package/dist/tools/file-summary.js +9 -0
- package/dist/tools/{fs.js → file-write.js} +5 -193
- package/dist/tools/glob.js +107 -0
- package/dist/tools/index.js +2 -1
- package/dist/tools/search.js +1 -105
- package/dist/tui/app-workflows.js +8 -361
- package/dist/tui/approve.js +5 -3
- package/dist/tui/blocks.js +8 -7
- package/dist/tui/command-workflow.js +106 -0
- package/dist/tui/components/command-menu.js +7 -10
- package/dist/tui/components/menu.js +74 -43
- package/dist/tui/components/messages.js +16 -9
- package/dist/tui/components/tool-evidence.js +107 -0
- package/dist/tui/components/tool-motion.js +32 -0
- package/dist/tui/components/tool.js +48 -202
- package/dist/tui/help.js +1 -1
- package/dist/tui/picker-layout.js +40 -0
- package/dist/tui/picker.js +7 -71
- package/dist/tui/tool-details.js +135 -0
- package/dist/tui/transcript-grammar.js +8 -1
- package/dist/tui/transcript-view.js +26 -112
- package/dist/tui/turn-workflow.js +264 -0
- package/dist/tui/turn.js +7 -140
- package/dist/tui/workflow-types.js +2 -0
- package/package.json +12 -12
- package/dist/ollama-settings-command.js +0 -74
- package/dist/tui/motion.js +0 -32
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Bounded session-file IO and cleanup of verified temporary directories.
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { lstat, opendir, rename, rm } from "node:fs/promises";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { BoundedFileError, readBoundedText, stableFileExpectation } from "../bounded-file.js";
|
|
6
|
+
import { CONVERSATION_LIMITS } from "../conversation.js";
|
|
7
|
+
import { assertDirectoryAnchor, captureDirectDirectory } from "../directory-anchor.js";
|
|
8
|
+
import { sameFileIdentity } from "../file-identity.js";
|
|
9
|
+
import { decodeNode, SESSION_FILE_LIMITS } from "./codec.js";
|
|
10
|
+
export const DIRECTORY_MODE = 0o700;
|
|
11
|
+
export const FILE_MODE = 0o600;
|
|
12
|
+
export const SESSION_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
|
13
|
+
const MAX_NODE_READ_CONCURRENCY = 8;
|
|
14
|
+
const MAX_NODE_READ_IN_FLIGHT_BYTES = 64 * 1_024 * 1_024;
|
|
15
|
+
const MAX_SESSION_NODE_BYTES = 192 * 1_024 * 1_024;
|
|
16
|
+
const NODE_READ_CONCURRENCY = Math.max(1, Math.min(MAX_NODE_READ_CONCURRENCY, Math.floor(MAX_NODE_READ_IN_FLIGHT_BYTES / SESSION_FILE_LIMITS.nodeBytes)));
|
|
17
|
+
const NODE_NAME = /^(\d{6})\.json$/;
|
|
18
|
+
const ATOMIC_NODE_TEMP = /^\.\d{6}\.json\.\d+\.[a-f0-9-]+\.tmp$/;
|
|
19
|
+
export async function assertMissingNode(file, validate) {
|
|
20
|
+
await validate?.();
|
|
21
|
+
try {
|
|
22
|
+
await lstat(file);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (error.code === "ENOENT") {
|
|
26
|
+
await validate?.();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
await validate?.();
|
|
32
|
+
throw new Error("session has an incomplete node outside its verified snapshot");
|
|
33
|
+
}
|
|
34
|
+
export async function readNodes(directory, validate) {
|
|
35
|
+
await validate();
|
|
36
|
+
await assertDirectoryAnchor(directory);
|
|
37
|
+
const names = [];
|
|
38
|
+
let entries = 0;
|
|
39
|
+
const handle = await opendir(directory.path);
|
|
40
|
+
for await (const entry of handle) {
|
|
41
|
+
entries++;
|
|
42
|
+
if (entries > CONVERSATION_LIMITS.nodes + 64) {
|
|
43
|
+
throw new Error("session node directory contains unsupported data");
|
|
44
|
+
}
|
|
45
|
+
if (!entry.isFile() || (!NODE_NAME.test(entry.name) && !ATOMIC_NODE_TEMP.test(entry.name))) {
|
|
46
|
+
throw new Error("session node directory contains unsupported data");
|
|
47
|
+
}
|
|
48
|
+
if (NODE_NAME.test(entry.name))
|
|
49
|
+
names.push(entry.name);
|
|
50
|
+
}
|
|
51
|
+
await validate();
|
|
52
|
+
await assertDirectoryAnchor(directory);
|
|
53
|
+
names.sort();
|
|
54
|
+
if (names.length === 0 || names.length > CONVERSATION_LIMITS.nodes) {
|
|
55
|
+
throw new Error("session has an invalid conversation size");
|
|
56
|
+
}
|
|
57
|
+
const files = [];
|
|
58
|
+
let storedBytes = 0;
|
|
59
|
+
for (let index = 0; index < names.length; index++) {
|
|
60
|
+
const name = names[index];
|
|
61
|
+
const id = Number(NODE_NAME.exec(name)?.[1]);
|
|
62
|
+
if (id !== index + 1) {
|
|
63
|
+
throw new Error("session conversation nodes are not contiguous");
|
|
64
|
+
}
|
|
65
|
+
const details = await lstat(path.join(directory.path, name), { bigint: true });
|
|
66
|
+
if (details.isSymbolicLink() || !details.isFile() || details.size < 0n ||
|
|
67
|
+
details.size > BigInt(SESSION_FILE_LIMITS.nodeBytes))
|
|
68
|
+
throw new Error("session node file is unsafe or too large");
|
|
69
|
+
storedBytes += Number(details.size);
|
|
70
|
+
if (storedBytes > MAX_SESSION_NODE_BYTES) {
|
|
71
|
+
throw new Error("session node files exceed their aggregate storage limit");
|
|
72
|
+
}
|
|
73
|
+
files.push({ name, id, expected: stableFileExpectation(details) });
|
|
74
|
+
}
|
|
75
|
+
await validate();
|
|
76
|
+
const stored = [];
|
|
77
|
+
const sequences = new Set();
|
|
78
|
+
let messageCodeUnits = 0;
|
|
79
|
+
let transcriptCodeUnits = 0;
|
|
80
|
+
let contextCodeUnits = 0;
|
|
81
|
+
for (let start = 0; start < files.length; start += NODE_READ_CONCURRENCY) {
|
|
82
|
+
const decoded = await Promise.all(files.slice(start, start + NODE_READ_CONCURRENCY)
|
|
83
|
+
.map(async ({ name, id, expected }) => {
|
|
84
|
+
const entry = decodeNode(await readJson(path.join(directory.path, name), SESSION_FILE_LIMITS.nodeBytes, expected));
|
|
85
|
+
if (entry.node.id !== id) {
|
|
86
|
+
throw new Error("session conversation node identity is invalid");
|
|
87
|
+
}
|
|
88
|
+
return entry;
|
|
89
|
+
}));
|
|
90
|
+
for (const entry of decoded) {
|
|
91
|
+
if (sequences.has(entry.sequence)) {
|
|
92
|
+
throw new Error("session conversation node identity is invalid");
|
|
93
|
+
}
|
|
94
|
+
messageCodeUnits += JSON.stringify(entry.node.messages).length;
|
|
95
|
+
transcriptCodeUnits += JSON.stringify(entry.node.blocks).length;
|
|
96
|
+
contextCodeUnits += entry.node.context?.summary.length ?? 0;
|
|
97
|
+
if (messageCodeUnits > CONVERSATION_LIMITS.messageCodeUnits ||
|
|
98
|
+
transcriptCodeUnits > CONVERSATION_LIMITS.transcriptCodeUnits ||
|
|
99
|
+
contextCodeUnits > CONVERSATION_LIMITS.contextCodeUnits) {
|
|
100
|
+
throw new Error("session conversation exceeds its aggregate limit");
|
|
101
|
+
}
|
|
102
|
+
sequences.add(entry.sequence);
|
|
103
|
+
stored.push(entry);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
await validate();
|
|
107
|
+
return stored;
|
|
108
|
+
}
|
|
109
|
+
export async function readJson(file, limit, expected, validate) {
|
|
110
|
+
try {
|
|
111
|
+
return JSON.parse(await readBoundedText(file, limit, {
|
|
112
|
+
label: "session file",
|
|
113
|
+
expected,
|
|
114
|
+
validate,
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (error instanceof SyntaxError) {
|
|
119
|
+
throw new Error("session file is not valid JSON");
|
|
120
|
+
}
|
|
121
|
+
if (error instanceof BoundedFileError) {
|
|
122
|
+
throw new Error("session file is unsafe or too large, or changed while opening");
|
|
123
|
+
}
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
export async function removeTemporaryDirectory(directory, bucket, expected) {
|
|
128
|
+
const relative = path.relative(bucket.path, directory);
|
|
129
|
+
if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative) ||
|
|
130
|
+
!path.basename(directory).startsWith(".") || !path.basename(directory).endsWith(".tmp"))
|
|
131
|
+
throw new Error("refusing to remove an unverified session directory");
|
|
132
|
+
await assertDirectoryAnchor(bucket);
|
|
133
|
+
let observed;
|
|
134
|
+
try {
|
|
135
|
+
observed = await captureDirectDirectory(directory, "temporary session directory");
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
if (error.code === "ENOENT")
|
|
139
|
+
return;
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
if (expected !== undefined &&
|
|
143
|
+
!sameFileIdentity(expected.identity, observed.identity))
|
|
144
|
+
throw new Error("refusing to remove a replaced session directory");
|
|
145
|
+
const quarantine = path.join(bucket.path, `.discard-${process.pid}-${randomUUID()}.tmp`);
|
|
146
|
+
await assertDirectoryAnchor(bucket);
|
|
147
|
+
await rename(directory, quarantine);
|
|
148
|
+
const moved = await captureDirectDirectory(quarantine, "discarded session directory");
|
|
149
|
+
if (!sameFileIdentity(observed.identity, moved.identity)) {
|
|
150
|
+
throw new Error("session cleanup target changed during quarantine");
|
|
151
|
+
}
|
|
152
|
+
await assertDirectoryAnchor(bucket);
|
|
153
|
+
await assertDirectoryAnchor(moved);
|
|
154
|
+
await rm(quarantine, { recursive: true });
|
|
155
|
+
}
|
|
156
|
+
export function nodeName(id) {
|
|
157
|
+
return `${String(id).padStart(6, "0")}.json`;
|
|
158
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Strict full-session loading and one adjacent checkpoint recovery.
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { atomicWrite } from "../atomic.js";
|
|
4
|
+
import { ConversationTree } from "../conversation.js";
|
|
5
|
+
import { assertDirectoryAnchor, captureDirectDirectory } from "../directory-anchor.js";
|
|
6
|
+
import { assertSessionId } from "./bucket.js";
|
|
7
|
+
import { sameSessionHead, sessionCatalog } from "./catalog.js";
|
|
8
|
+
import { decodeHead, decodeMeta, encodeHead, SESSION_FILE_LIMITS, SESSION_SCHEMA } from "./codec.js";
|
|
9
|
+
import { FILE_MODE, readJson, readNodes } from "./files.js";
|
|
10
|
+
import { sessionLeaseOwns } from "./lease.js";
|
|
11
|
+
export async function loadSession(bucket, id, scope, recoveryLease) {
|
|
12
|
+
assertSessionId(id);
|
|
13
|
+
await bucket.assert();
|
|
14
|
+
const directory = bucket.directory(id);
|
|
15
|
+
const directoryAnchor = await captureDirectDirectory(directory, "session directory");
|
|
16
|
+
const nodesAnchor = await captureDirectDirectory(path.join(directory, "nodes"), "session node directory");
|
|
17
|
+
const validateSession = async () => {
|
|
18
|
+
await Promise.all([
|
|
19
|
+
bucket.assert(),
|
|
20
|
+
assertDirectoryAnchor(directoryAnchor),
|
|
21
|
+
assertDirectoryAnchor(nodesAnchor),
|
|
22
|
+
]);
|
|
23
|
+
};
|
|
24
|
+
const meta = decodeMeta(await readJson(path.join(directory, "meta.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateSession));
|
|
25
|
+
bucket.assertWorkspace(meta, id);
|
|
26
|
+
const persistedHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateSession));
|
|
27
|
+
let head = persistedHead;
|
|
28
|
+
const stored = await readNodes(nodesAnchor, validateSession);
|
|
29
|
+
const ahead = stored.filter((entry) => entry.sequence > head.sequence);
|
|
30
|
+
if (ahead.some((entry) => entry.sequence !== head.sequence + 1) || ahead.length > 1) {
|
|
31
|
+
throw new Error("session has an ambiguous incomplete checkpoint");
|
|
32
|
+
}
|
|
33
|
+
const byId = new Map(stored.map((entry) => [entry.node.id, entry]));
|
|
34
|
+
const headed = byId.get(head.nodeId);
|
|
35
|
+
if (headed === undefined)
|
|
36
|
+
throw new Error("session head is missing its conversation node");
|
|
37
|
+
if (ahead.length === 0) {
|
|
38
|
+
if (headed.sequence !== head.sequence || headed.node.revision !== head.revision ||
|
|
39
|
+
headed.node.parentId !== head.parentId) {
|
|
40
|
+
throw new Error("session head does not match its conversation node");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
const candidate = ahead[0];
|
|
45
|
+
const replacesHead = candidate.node.id === head.nodeId &&
|
|
46
|
+
candidate.node.parentId === head.parentId &&
|
|
47
|
+
candidate.node.revision === head.revision + 1;
|
|
48
|
+
const candidateParent = byId.get(candidate.node.parentId);
|
|
49
|
+
const extendsTree = candidate.node.id === stored.length &&
|
|
50
|
+
candidate.node.revision === 1 && candidateParent !== undefined &&
|
|
51
|
+
candidateParent.sequence <= head.sequence;
|
|
52
|
+
if (!replacesHead && !extendsTree) {
|
|
53
|
+
throw new Error("session checkpoint cannot be recovered safely");
|
|
54
|
+
}
|
|
55
|
+
if (recoveryLease === undefined ||
|
|
56
|
+
!sessionLeaseOwns(recoveryLease, id, scope)) {
|
|
57
|
+
throw new Error("session recovery requires exclusive ownership");
|
|
58
|
+
}
|
|
59
|
+
await recoveryLease.assertOwned();
|
|
60
|
+
head = Object.freeze({
|
|
61
|
+
version: SESSION_SCHEMA,
|
|
62
|
+
sequence: candidate.sequence,
|
|
63
|
+
nodeId: candidate.node.id,
|
|
64
|
+
parentId: candidate.node.parentId,
|
|
65
|
+
revision: candidate.node.revision,
|
|
66
|
+
updatedAt: candidate.updatedAt,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
// Recovery must pass all turn, tree, and catalogue invariants before it
|
|
70
|
+
// changes durable state; the file codec alone cannot validate the tree.
|
|
71
|
+
const nodes = stored.map((entry) => entry.node);
|
|
72
|
+
const conversation = ConversationTree.restore(nodes, head.nodeId);
|
|
73
|
+
const catalog = sessionCatalog(meta, head, conversation);
|
|
74
|
+
if (head !== persistedHead && recoveryLease !== undefined) {
|
|
75
|
+
await atomicWrite(path.join(directory, "head.json"), encodeHead(head), {
|
|
76
|
+
mode: FILE_MODE,
|
|
77
|
+
validate: async (phase) => {
|
|
78
|
+
await validateSession();
|
|
79
|
+
if (phase !== "before-rename")
|
|
80
|
+
return;
|
|
81
|
+
await recoveryLease.assertOwned();
|
|
82
|
+
const current = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateSession));
|
|
83
|
+
if (!sameSessionHead(current, persistedHead)) {
|
|
84
|
+
throw new Error("session head changed while recovering its checkpoint");
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return Object.freeze({ meta, head, conversation, catalog });
|
|
90
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Verified in-memory checkpoint identity shared by persistence operations.
|
|
2
|
+
import { catalogMatches, encodeSessionCatalog } from "./catalog.js";
|
|
3
|
+
import { encodeHead, encodeMeta } from "./codec.js";
|
|
4
|
+
import { assertSessionId, workspaceKey } from "./bucket.js";
|
|
5
|
+
export function assertSharedNodes(previous, next, replacedId) {
|
|
6
|
+
for (const node of previous.nodes) {
|
|
7
|
+
if (node.id === replacedId)
|
|
8
|
+
continue;
|
|
9
|
+
const candidate = next.node(node.id);
|
|
10
|
+
if (candidate !== node) {
|
|
11
|
+
throw new Error("session checkpoint rewrites prior conversation history");
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function assertSnapshot(snapshot, workspaceRoot, workspaceDigest) {
|
|
16
|
+
encodeMeta(snapshot.meta);
|
|
17
|
+
encodeHead(snapshot.head);
|
|
18
|
+
encodeSessionCatalog(snapshot.catalog);
|
|
19
|
+
assertSessionId(snapshot.meta.id);
|
|
20
|
+
if (snapshot.meta.workspaceDigest !== workspaceDigest ||
|
|
21
|
+
workspaceKey(snapshot.meta.workspaceRoot) !== workspaceKey(workspaceRoot))
|
|
22
|
+
throw new Error("session snapshot belongs to a different workspace");
|
|
23
|
+
const active = snapshot.conversation.activeNode;
|
|
24
|
+
if (active === undefined ||
|
|
25
|
+
active.id !== snapshot.head.nodeId ||
|
|
26
|
+
active.parentId !== snapshot.head.parentId ||
|
|
27
|
+
active.revision !== snapshot.head.revision ||
|
|
28
|
+
snapshot.head.sequence < snapshot.conversation.nodes.length)
|
|
29
|
+
throw new Error("session snapshot does not match its verified head");
|
|
30
|
+
if (!catalogMatches(snapshot.catalog, snapshot.meta, snapshot.head)) {
|
|
31
|
+
throw new Error("session snapshot does not match its verified catalogue");
|
|
32
|
+
}
|
|
33
|
+
}
|