@giovannijecha/jecode 0.8.3 → 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 +3 -2
- package/assets/wordmark-steel.svg +3 -0
- package/dist/batch.js +13 -1
- package/dist/config.js +6 -3
- package/dist/provider-commands.js +43 -6
- package/dist/provider-errors.js +59 -1
- package/dist/providers/http.js +4 -34
- package/dist/providers/openai-codex.js +1 -1
- package/dist/providers/openai-stream.js +43 -7
- package/dist/providers/openai.js +1 -1
- package/dist/providers/sse.js +45 -17
- package/dist/sessions/catalog.js +199 -0
- package/dist/sessions/store.js +110 -66
- package/dist/tools/search.js +27 -18
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -175,8 +175,9 @@ configuration, and safety boundaries.
|
|
|
175
175
|
|
|
176
176
|
Public pull requests are not accepted at this stage. Code changes remain a
|
|
177
177
|
maintainer and invited-collaborator workflow. See
|
|
178
|
-
[CONTRIBUTING.md](CONTRIBUTING.md)
|
|
179
|
-
|
|
178
|
+
[CONTRIBUTING.md](https://github.com/giovannijecha/jecode/blob/main/CONTRIBUTING.md)
|
|
179
|
+
and
|
|
180
|
+
[CODE_OF_CONDUCT.md](https://github.com/giovannijecha/jecode/blob/main/CODE_OF_CONDUCT.md).
|
|
180
181
|
|
|
181
182
|
## License
|
|
182
183
|
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 180" role="img" aria-label="jecode">
|
|
2
|
+
<text x="380" y="128" fill="#669BD2" font-family="Inter,Nunito Sans,system-ui,sans-serif" font-size="132" font-weight="650" letter-spacing="-7" text-anchor="middle">jecode</text>
|
|
3
|
+
</svg>
|
package/dist/batch.js
CHANGED
|
@@ -14,6 +14,7 @@ import { columns } from "./ui/render.js";
|
|
|
14
14
|
import { terminalText } from "./ui/terminal-text.js";
|
|
15
15
|
import { recordAuxiliaryUsage, recordRequestInput, recordUsage } from "./usage.js";
|
|
16
16
|
import { assertPromptLength, boundedInputLines } from "./input-boundary.js";
|
|
17
|
+
import { providerFailure } from "./provider-errors.js";
|
|
17
18
|
export async function runBatch(session, environment = {}) {
|
|
18
19
|
const write = environment.write ?? ((text) => stdout.write(text));
|
|
19
20
|
const width = environment.width ?? columns();
|
|
@@ -115,7 +116,18 @@ export async function runBatch(session, environment = {}) {
|
|
|
115
116
|
commit(checkpoint, settlement);
|
|
116
117
|
return compacted;
|
|
117
118
|
};
|
|
118
|
-
|
|
119
|
+
try {
|
|
120
|
+
await runTurn(history, options(session, policy), turn, signal, modelHistory);
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
throwIfAborted(signal);
|
|
124
|
+
if (!(error instanceof Error))
|
|
125
|
+
throw error;
|
|
126
|
+
const message = providerFailure(session.provider, error);
|
|
127
|
+
if (message === error.message)
|
|
128
|
+
throw error;
|
|
129
|
+
throw new Error(message, { cause: error });
|
|
130
|
+
}
|
|
119
131
|
turn.flush();
|
|
120
132
|
}
|
|
121
133
|
throwIfAborted(signal);
|
package/dist/config.js
CHANGED
|
@@ -43,9 +43,7 @@ export function loadConfig(argv, saved = readSettings()) {
|
|
|
43
43
|
: { maxModelRequests: toInt(maxModelRequests, "max-steps") }),
|
|
44
44
|
compactionPercent: toPercent(pick(flags["compaction-percent"], process.env.JECODE_COMPACTION_PERCENT, String(saved.compactionPercent ?? DEFAULT_COMPACTION_PERCENT))),
|
|
45
45
|
root: path.resolve(pick(flags.root, undefined, process.cwd())),
|
|
46
|
-
autoApprove: flags["auto-approve"]
|
|
47
|
-
flags["auto-approve"] === "1" ||
|
|
48
|
-
process.env.JECODE_AUTO_APPROVE === "1",
|
|
46
|
+
autoApprove: autoApproval(flags["auto-approve"], process.env.JECODE_AUTO_APPROVE),
|
|
49
47
|
ephemeral: bool(flags.ephemeral, process.env.JECODE_EPHEMERAL, false),
|
|
50
48
|
};
|
|
51
49
|
}
|
|
@@ -56,6 +54,11 @@ function bool(flag, env, fallback) {
|
|
|
56
54
|
return env === "true" || env === "1";
|
|
57
55
|
return fallback;
|
|
58
56
|
}
|
|
57
|
+
function autoApproval(flag, env) {
|
|
58
|
+
if (flag !== undefined)
|
|
59
|
+
return flag === "true" || flag === "1";
|
|
60
|
+
return env === "1";
|
|
61
|
+
}
|
|
59
62
|
function pick(flag, env, fallback) {
|
|
60
63
|
if (flag !== undefined && flag !== "")
|
|
61
64
|
return flag;
|
|
@@ -15,28 +15,65 @@ export async function providersCommand(session, host) {
|
|
|
15
15
|
const choose = chooser(host);
|
|
16
16
|
if (choose === undefined)
|
|
17
17
|
return;
|
|
18
|
-
|
|
18
|
+
const groups = providerGroups();
|
|
19
|
+
let selected = Math.max(0, groups.findIndex((group) => group.providers.some((provider) => provider.id === session.provider.id)));
|
|
19
20
|
while (true) {
|
|
20
21
|
const index = await choose({
|
|
21
22
|
title: [],
|
|
22
|
-
options:
|
|
23
|
+
options: groups.map((group) => ({
|
|
24
|
+
label: group.label,
|
|
25
|
+
value: providerCount(group.providers.length),
|
|
26
|
+
})),
|
|
27
|
+
index: selected,
|
|
28
|
+
});
|
|
29
|
+
if (index === undefined) {
|
|
30
|
+
throwIfAborted(host.signal);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const group = groups[index];
|
|
34
|
+
if (group === undefined)
|
|
35
|
+
return;
|
|
36
|
+
selected = index;
|
|
37
|
+
await providerGroupCommand(group, session, host);
|
|
38
|
+
throwIfAborted(host.signal);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function providerGroups() {
|
|
42
|
+
return [
|
|
43
|
+
{ label: "Account", providers: PROVIDERS.filter((provider) => provider.auth.kind === "oauth") },
|
|
44
|
+
{ label: "API", providers: PROVIDERS.filter((provider) => provider.auth.kind !== "oauth") },
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
async function providerGroupCommand(group, session, host) {
|
|
48
|
+
if (host.choose === undefined)
|
|
49
|
+
return;
|
|
50
|
+
let selected = Math.max(0, group.providers.findIndex((provider) => provider.id === session.provider.id));
|
|
51
|
+
while (true) {
|
|
52
|
+
const index = await host.choose({
|
|
53
|
+
title: heading(group.label, "provider access", session.palette),
|
|
54
|
+
options: group.providers.map((provider) => ({
|
|
23
55
|
label: providerLabel(provider.id),
|
|
24
56
|
value: providerAccessHint(provider),
|
|
25
57
|
})),
|
|
26
58
|
index: selected,
|
|
27
59
|
});
|
|
28
|
-
if (index === undefined)
|
|
60
|
+
if (index === undefined) {
|
|
61
|
+
throwIfAborted(host.signal);
|
|
29
62
|
return;
|
|
30
|
-
|
|
63
|
+
}
|
|
64
|
+
const provider = group.providers[index];
|
|
31
65
|
if (provider === undefined)
|
|
32
66
|
return;
|
|
33
67
|
selected = index;
|
|
34
68
|
await manageProvider(provider, session, host);
|
|
35
|
-
// Esc closes only the
|
|
36
|
-
//
|
|
69
|
+
// Esc closes only the provider-specific flow. Ctrl+C also settles that
|
|
70
|
+
// interaction, but aborts the command signal and must not reopen a menu.
|
|
37
71
|
throwIfAborted(host.signal);
|
|
38
72
|
}
|
|
39
73
|
}
|
|
74
|
+
function providerCount(count) {
|
|
75
|
+
return `${count} ${count === 1 ? "provider" : "providers"}`;
|
|
76
|
+
}
|
|
40
77
|
export function providerAccessHint(provider) {
|
|
41
78
|
if (provider.id === "ollama") {
|
|
42
79
|
const connection = ollamaConnection();
|
package/dist/provider-errors.js
CHANGED
|
@@ -1,11 +1,69 @@
|
|
|
1
1
|
// Actionable provider failures for user-facing command and turn surfaces.
|
|
2
|
+
import { redactCredentials } from "./credential-safety.js";
|
|
2
3
|
import { providerLabel } from "./provider-label.js";
|
|
4
|
+
import { leadingText } from "./text-boundary.js";
|
|
5
|
+
import { terminalText } from "./ui/terminal-text.js";
|
|
3
6
|
const CONNECTION_FAILURE = /network error calling|timed out waiting for response headers|response body was idle/i;
|
|
7
|
+
const MAX_MESSAGE_CHARS = 1_000;
|
|
8
|
+
const MAX_REASON_CHARS = 500;
|
|
9
|
+
const HTML = /<!doctype\s|<\/?[a-z][^>]*>/i;
|
|
4
10
|
export function providerFailure(provider, error, labelProvider = false) {
|
|
5
11
|
if (provider.id === "ollama" && CONNECTION_FAILURE.test(error.message)) {
|
|
6
12
|
return provider.location?.() === "local"
|
|
7
13
|
? "Ollama is not reachable on this computer · start Ollama or choose cloud in /providers"
|
|
8
14
|
: "Ollama is not reachable · check its connection in /providers";
|
|
9
15
|
}
|
|
10
|
-
|
|
16
|
+
const message = safeText(error.message, MAX_MESSAGE_CHARS) || "provider request failed";
|
|
17
|
+
const reason = providerReason(error);
|
|
18
|
+
const detail = reason !== undefined && !message.toLocaleLowerCase().includes(reason.toLocaleLowerCase())
|
|
19
|
+
? ` · ${reason}`
|
|
20
|
+
: "";
|
|
21
|
+
const failure = `${message}${detail}`;
|
|
22
|
+
return labelProvider ? `${providerLabel(provider.id)}: ${failure}` : failure;
|
|
23
|
+
}
|
|
24
|
+
function providerReason(error) {
|
|
25
|
+
const body = error.body;
|
|
26
|
+
if (typeof body !== "string" || body.trim() === "")
|
|
27
|
+
return undefined;
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
parsed = JSON.parse(body);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
const raw = body.trim();
|
|
34
|
+
if (raw.startsWith("{") || raw.startsWith("[") || HTML.test(raw))
|
|
35
|
+
return undefined;
|
|
36
|
+
return safeReason(raw);
|
|
37
|
+
}
|
|
38
|
+
for (const candidate of reasonCandidates(parsed)) {
|
|
39
|
+
const reason = safeReason(candidate);
|
|
40
|
+
if (reason !== undefined)
|
|
41
|
+
return reason;
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
function reasonCandidates(value) {
|
|
46
|
+
if (typeof value === "string")
|
|
47
|
+
return [value];
|
|
48
|
+
if (!record(value))
|
|
49
|
+
return [];
|
|
50
|
+
const error = value["error"];
|
|
51
|
+
return [
|
|
52
|
+
record(error) ? error["message"] : undefined,
|
|
53
|
+
typeof error === "string" ? error : undefined,
|
|
54
|
+
value["message"],
|
|
55
|
+
value["detail"],
|
|
56
|
+
].filter((candidate) => typeof candidate === "string");
|
|
57
|
+
}
|
|
58
|
+
function safeReason(text) {
|
|
59
|
+
if (HTML.test(text))
|
|
60
|
+
return undefined;
|
|
61
|
+
return safeText(text, MAX_REASON_CHARS) || undefined;
|
|
62
|
+
}
|
|
63
|
+
function safeText(text, max) {
|
|
64
|
+
const redacted = redactCredentials(text).trim().replace(/\s+/gu, " ");
|
|
65
|
+
return leadingText(terminalText(redacted), max);
|
|
66
|
+
}
|
|
67
|
+
function record(value) {
|
|
68
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
11
69
|
}
|
package/dist/providers/http.js
CHANGED
|
@@ -40,7 +40,10 @@ export async function postSse(url, headers, body, maxOutputTokens, signal, onSta
|
|
|
40
40
|
const res = await request(url, { accept: "text/event-stream", ...headers }, body, signal, onStatus);
|
|
41
41
|
if (res.body === null)
|
|
42
42
|
throw httpError(`${url} returned no body`, res.status);
|
|
43
|
-
return readSseJson(
|
|
43
|
+
return readSseJson(res.body, maximumChars, {
|
|
44
|
+
milliseconds: BODY_IDLE_TIMEOUT_MS,
|
|
45
|
+
error: () => httpError(`${url} SSE stream was idle for ${BODY_IDLE_TIMEOUT_MS}ms without an event`, res.status),
|
|
46
|
+
});
|
|
44
47
|
}
|
|
45
48
|
async function request(url, headers, body, signal, onStatus) {
|
|
46
49
|
const maxRetries = body === undefined ? GET_RETRIES : 0;
|
|
@@ -154,39 +157,6 @@ async function timedRead(url, reader) {
|
|
|
154
157
|
clearTimeout(timer);
|
|
155
158
|
}
|
|
156
159
|
}
|
|
157
|
-
function withIdleTimeout(url, body) {
|
|
158
|
-
const reader = body.getReader();
|
|
159
|
-
let released = false;
|
|
160
|
-
const release = () => {
|
|
161
|
-
if (released)
|
|
162
|
-
return;
|
|
163
|
-
released = true;
|
|
164
|
-
reader.releaseLock();
|
|
165
|
-
};
|
|
166
|
-
return new ReadableStream({
|
|
167
|
-
async pull(controller) {
|
|
168
|
-
try {
|
|
169
|
-
const { done, value } = await timedRead(url, reader);
|
|
170
|
-
if (done) {
|
|
171
|
-
release();
|
|
172
|
-
controller.close();
|
|
173
|
-
}
|
|
174
|
-
else {
|
|
175
|
-
controller.enqueue(value);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
catch (error) {
|
|
179
|
-
await reader.cancel(error).catch(() => undefined);
|
|
180
|
-
release();
|
|
181
|
-
controller.error(error);
|
|
182
|
-
}
|
|
183
|
-
},
|
|
184
|
-
async cancel(reason) {
|
|
185
|
-
await reader.cancel(reason).catch(() => undefined);
|
|
186
|
-
release();
|
|
187
|
-
},
|
|
188
|
-
});
|
|
189
|
-
}
|
|
190
160
|
function waitLabel(ms) {
|
|
191
161
|
return ms < 1_000 ? `${ms}ms` : `${Math.ceil(ms / 1_000)}s`;
|
|
192
162
|
}
|
|
@@ -72,7 +72,7 @@ export const openaiCodex = {
|
|
|
72
72
|
include: ["reasoning.encrypted_content"],
|
|
73
73
|
prompt_cache_key: SESSION_ID,
|
|
74
74
|
}, req.maxTokens, req.signal, req.onStatus);
|
|
75
|
-
const data = await assembleOpenAI(events, req.onStream);
|
|
75
|
+
const data = await assembleOpenAI(events, req.onStream, req.onStatus);
|
|
76
76
|
const notice = stopNotice(data);
|
|
77
77
|
if (notice !== undefined)
|
|
78
78
|
req.onStream?.({ kind: "text", text: `\n${notice}` });
|
|
@@ -4,40 +4,70 @@
|
|
|
4
4
|
// response in `response.completed`. The ChatGPT Codex backend can instead send
|
|
5
5
|
// an empty final `output` after complete `response.output_item.done` events, so
|
|
6
6
|
// those streamed items remain the fallback when the final envelope is empty.
|
|
7
|
-
export async function assembleOpenAI(events, onStream) {
|
|
7
|
+
export async function assembleOpenAI(events, onStream, onStatus) {
|
|
8
8
|
const items = [];
|
|
9
9
|
const announcedTools = { identities: new Set(), anonymous: false };
|
|
10
10
|
let refusal = false;
|
|
11
|
+
let activity;
|
|
12
|
+
const status = (next) => {
|
|
13
|
+
if (activity === next)
|
|
14
|
+
return;
|
|
15
|
+
activity = next;
|
|
16
|
+
onStatus?.(next);
|
|
17
|
+
};
|
|
11
18
|
for await (const raw of events) {
|
|
12
19
|
const event = raw;
|
|
13
20
|
switch (event.type) {
|
|
21
|
+
case "response.created":
|
|
22
|
+
case "response.in_progress":
|
|
23
|
+
if (activity === undefined)
|
|
24
|
+
status("Working");
|
|
25
|
+
break;
|
|
14
26
|
case "response.output_text.delta":
|
|
15
|
-
if (typeof event.delta === "string")
|
|
27
|
+
if (typeof event.delta === "string") {
|
|
28
|
+
status("Responding");
|
|
16
29
|
onStream?.({ kind: "text", text: event.delta });
|
|
30
|
+
}
|
|
17
31
|
break;
|
|
18
32
|
case "response.refusal.delta":
|
|
19
33
|
if (typeof event.delta === "string") {
|
|
34
|
+
status("Responding");
|
|
20
35
|
onStream?.({ kind: "text", text: `${refusal ? "" : "[refused] "}${event.delta}` });
|
|
21
36
|
refusal = true;
|
|
22
37
|
}
|
|
23
38
|
break;
|
|
24
39
|
case "response.reasoning_summary_text.delta":
|
|
25
|
-
if (typeof event.delta === "string")
|
|
40
|
+
if (typeof event.delta === "string") {
|
|
41
|
+
status("Thinking");
|
|
26
42
|
onStream?.({ kind: "thinking", text: event.delta });
|
|
43
|
+
}
|
|
44
|
+
break;
|
|
45
|
+
case "response.reasoning_summary_part.added":
|
|
46
|
+
status("Thinking");
|
|
47
|
+
break;
|
|
48
|
+
case "response.reasoning_summary_text.done":
|
|
49
|
+
case "response.reasoning_summary_part.done":
|
|
50
|
+
status("Working");
|
|
27
51
|
break;
|
|
28
52
|
case "response.output_item.added":
|
|
29
53
|
if (isFunctionCall(event.item)) {
|
|
30
|
-
announceTool(event, event.item, announcedTools, onStream);
|
|
54
|
+
announceTool(event, event.item, announcedTools, onStream, status);
|
|
55
|
+
}
|
|
56
|
+
else if (itemType(event.item) === "reasoning") {
|
|
57
|
+
status("Thinking");
|
|
58
|
+
}
|
|
59
|
+
else if (itemType(event.item) === "message") {
|
|
60
|
+
status("Responding");
|
|
31
61
|
}
|
|
32
62
|
break;
|
|
33
63
|
case "response.function_call_arguments.delta":
|
|
34
64
|
case "response.function_call_arguments.done":
|
|
35
|
-
announceTool(event, undefined, announcedTools, onStream);
|
|
65
|
+
announceTool(event, undefined, announcedTools, onStream, status);
|
|
36
66
|
break;
|
|
37
67
|
case "response.output_item.done":
|
|
38
68
|
if (event.item !== undefined) {
|
|
39
69
|
if (isFunctionCall(event.item)) {
|
|
40
|
-
announceTool(event, event.item, announcedTools, onStream);
|
|
70
|
+
announceTool(event, event.item, announcedTools, onStream, status);
|
|
41
71
|
}
|
|
42
72
|
items.push(event.item);
|
|
43
73
|
}
|
|
@@ -66,7 +96,12 @@ function isFunctionCall(item) {
|
|
|
66
96
|
return typeof item === "object" && item !== null &&
|
|
67
97
|
item["type"] === "function_call";
|
|
68
98
|
}
|
|
69
|
-
function
|
|
99
|
+
function itemType(item) {
|
|
100
|
+
return typeof item === "object" && item !== null
|
|
101
|
+
? item["type"]
|
|
102
|
+
: undefined;
|
|
103
|
+
}
|
|
104
|
+
function announceTool(event, item, announced, onStream, onStatus) {
|
|
70
105
|
const identities = toolIdentities(event, item);
|
|
71
106
|
if (identities.length === 0) {
|
|
72
107
|
if (announced.anonymous)
|
|
@@ -82,6 +117,7 @@ function announceTool(event, item, announced, onStream) {
|
|
|
82
117
|
}
|
|
83
118
|
const rawName = item?.name ?? event.name;
|
|
84
119
|
const name = typeof rawName === "string" && rawName !== "" ? rawName : undefined;
|
|
120
|
+
onStatus?.(`Preparing ${name ?? "tool"}`);
|
|
85
121
|
onStream?.({ kind: "tool", ...(name === undefined ? {} : { name }) });
|
|
86
122
|
}
|
|
87
123
|
function toolIdentities(event, item) {
|
package/dist/providers/openai.js
CHANGED
|
@@ -87,7 +87,7 @@ export const openai = {
|
|
|
87
87
|
include: ["reasoning.encrypted_content"],
|
|
88
88
|
stream: true,
|
|
89
89
|
}, req.maxTokens, req.signal, req.onStatus);
|
|
90
|
-
const data = await assembleOpenAI(events, req.onStream);
|
|
90
|
+
const data = await assembleOpenAI(events, req.onStream, req.onStatus);
|
|
91
91
|
const notice = stopNotice(data);
|
|
92
92
|
if (notice !== undefined)
|
|
93
93
|
req.onStream?.({ kind: "text", text: `\n${notice}` });
|
package/dist/providers/sse.js
CHANGED
|
@@ -4,31 +4,44 @@
|
|
|
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
6
|
import { addBounded, MAX_SSE_EVENT_CHARS, } from "./stream-limits.js";
|
|
7
|
-
export async function* readSseJson(body, maximumChars) {
|
|
7
|
+
export async function* readSseJson(body, maximumChars, idle) {
|
|
8
8
|
const reader = body.getReader();
|
|
9
9
|
const decoder = new TextDecoder();
|
|
10
10
|
const parser = new SseEventParser();
|
|
11
11
|
let finished = false;
|
|
12
12
|
let total = 0;
|
|
13
13
|
try {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
14
|
+
let ended = false;
|
|
15
|
+
while (!ended) {
|
|
16
|
+
// One deadline spans every raw read until a complete JSON event lands.
|
|
17
|
+
// SSE comments and partial framing prove only that the socket is alive;
|
|
18
|
+
// they must not keep a model request pending forever.
|
|
19
|
+
const deadline = eventDeadline(idle);
|
|
20
|
+
const payloads = [];
|
|
21
|
+
try {
|
|
22
|
+
while (payloads.length === 0 && !ended) {
|
|
23
|
+
const { done, value } = await deadline.wait(reader.read());
|
|
24
|
+
if (done) {
|
|
25
|
+
ended = true;
|
|
26
|
+
const text = decoder.decode();
|
|
27
|
+
total = addBounded(total, text.length, maximumChars, "SSE stream");
|
|
28
|
+
payloads.push(...parser.push(text));
|
|
29
|
+
const payload = parser.finish();
|
|
30
|
+
if (payload !== undefined)
|
|
31
|
+
payloads.push(payload);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const text = decoder.decode(value, { stream: true });
|
|
35
|
+
total = addBounded(total, text.length, maximumChars, "SSE stream");
|
|
36
|
+
payloads.push(...parser.push(text));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
deadline.clear();
|
|
41
|
+
}
|
|
42
|
+
for (const payload of payloads)
|
|
21
43
|
yield payload;
|
|
22
44
|
}
|
|
23
|
-
// A stream that ends without a trailing blank line still owes us its last
|
|
24
|
-
// event.
|
|
25
|
-
const text = decoder.decode();
|
|
26
|
-
total = addBounded(total, text.length, maximumChars, "SSE stream");
|
|
27
|
-
for (const payload of parser.push(text))
|
|
28
|
-
yield payload;
|
|
29
|
-
const payload = parser.finish();
|
|
30
|
-
if (payload !== undefined)
|
|
31
|
-
yield payload;
|
|
32
45
|
finished = true;
|
|
33
46
|
}
|
|
34
47
|
finally {
|
|
@@ -37,6 +50,21 @@ export async function* readSseJson(body, maximumChars) {
|
|
|
37
50
|
reader.releaseLock();
|
|
38
51
|
}
|
|
39
52
|
}
|
|
53
|
+
function eventDeadline(idle) {
|
|
54
|
+
if (idle === undefined)
|
|
55
|
+
return { wait: (pending) => pending, clear: () => undefined };
|
|
56
|
+
let timer;
|
|
57
|
+
const expired = new Promise((_resolve, reject) => {
|
|
58
|
+
timer = setTimeout(() => reject(idle.error()), idle.milliseconds);
|
|
59
|
+
});
|
|
60
|
+
return {
|
|
61
|
+
wait: (pending) => Promise.race([pending, expired]),
|
|
62
|
+
clear: () => {
|
|
63
|
+
if (timer !== undefined)
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
40
68
|
// Keep fragments in bounded groups. A provider may split one SSE line into
|
|
41
69
|
// hundreds of thousands of tiny chunks; repeatedly flattening the growing line
|
|
42
70
|
// would make parsing quadratic even if boundary scanning itself were linear.
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Small, head-tied projections for the resume catalogue.
|
|
2
|
+
//
|
|
3
|
+
// Conversation nodes remain authoritative and are fully decoded when a
|
|
4
|
+
// session is selected. This auxiliary record lets listing stay independent of
|
|
5
|
+
// every unselected tree's depth; a missing or suspect record falls back to the
|
|
6
|
+
// strict loader and can be rebuilt without changing the session schema.
|
|
7
|
+
import { Buffer } from "node:buffer";
|
|
8
|
+
import { CONVERSATION_LIMITS } from "../conversation.js";
|
|
9
|
+
import { leadingText } from "../text-boundary.js";
|
|
10
|
+
import { decodeHead, encodeHead } from "./codec.js";
|
|
11
|
+
export const SESSION_CATALOG_FILE = "catalog.json";
|
|
12
|
+
export const SESSION_CHECKPOINT_FILE = ".checkpoint";
|
|
13
|
+
export const SESSION_CATALOG_BYTES = 4 * 1_024;
|
|
14
|
+
const CATALOG_SCHEMA = 1;
|
|
15
|
+
const PREVIEW_CODE_UNITS = 160;
|
|
16
|
+
export function sessionCatalog(meta, head, conversation) {
|
|
17
|
+
assertActiveHead(head, conversation);
|
|
18
|
+
const resumable = conversation.latestResumable();
|
|
19
|
+
return own({
|
|
20
|
+
version: CATALOG_SCHEMA,
|
|
21
|
+
id: meta.id,
|
|
22
|
+
workspaceDigest: meta.workspaceDigest,
|
|
23
|
+
createdAt: meta.createdAt,
|
|
24
|
+
head,
|
|
25
|
+
resumeNodeId: resumable?.activeNodeId ?? 0,
|
|
26
|
+
turns: resumable === undefined ? 0 : selectedTurnCount(resumable),
|
|
27
|
+
preview: resumable === undefined ? "" : firstUserText(resumable),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/** Advance the common linear checkpoint path without walking prior turns. */
|
|
31
|
+
export function advanceSessionCatalog(previous, meta, head, conversation) {
|
|
32
|
+
assertActiveHead(head, conversation);
|
|
33
|
+
if (previous.id !== meta.id ||
|
|
34
|
+
previous.workspaceDigest !== meta.workspaceDigest ||
|
|
35
|
+
previous.createdAt !== meta.createdAt)
|
|
36
|
+
throw new Error("session catalogue does not match its metadata");
|
|
37
|
+
const active = conversation.activeNode;
|
|
38
|
+
const revisesHead = active.id === previous.head.nodeId &&
|
|
39
|
+
active.parentId === previous.head.parentId &&
|
|
40
|
+
active.revision === previous.head.revision + 1;
|
|
41
|
+
const extendsHead = active.parentId === previous.head.nodeId &&
|
|
42
|
+
active.id > previous.head.nodeId && active.revision === 1;
|
|
43
|
+
if (!revisesHead && !extendsHead)
|
|
44
|
+
return sessionCatalog(meta, head, conversation);
|
|
45
|
+
// A resumable child of an unfinished head makes that intermediate node part
|
|
46
|
+
// of the selected path. Rebuild in that uncommon case so the turn count is
|
|
47
|
+
// exact instead of assuming one visible turn was appended.
|
|
48
|
+
if (extendsHead && active.settlement !== "checkpointed" &&
|
|
49
|
+
previous.resumeNodeId !== previous.head.nodeId)
|
|
50
|
+
return sessionCatalog(meta, head, conversation);
|
|
51
|
+
if (active.settlement === "checkpointed" && previous.resumeNodeId === active.id) {
|
|
52
|
+
return sessionCatalog(meta, head, conversation);
|
|
53
|
+
}
|
|
54
|
+
const resumable = active.settlement !== "checkpointed";
|
|
55
|
+
const addsTurn = resumable && previous.resumeNodeId !== active.id;
|
|
56
|
+
const turns = previous.turns + (addsTurn ? 1 : 0);
|
|
57
|
+
const preview = turns === 0
|
|
58
|
+
? ""
|
|
59
|
+
: previous.turns === 0
|
|
60
|
+
? firstUserTextInNode(active)
|
|
61
|
+
: previous.preview;
|
|
62
|
+
return own({
|
|
63
|
+
version: CATALOG_SCHEMA,
|
|
64
|
+
id: meta.id,
|
|
65
|
+
workspaceDigest: meta.workspaceDigest,
|
|
66
|
+
createdAt: meta.createdAt,
|
|
67
|
+
head,
|
|
68
|
+
resumeNodeId: resumable ? active.id : previous.resumeNodeId,
|
|
69
|
+
turns,
|
|
70
|
+
preview,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
export function encodeSessionCatalog(catalog) {
|
|
74
|
+
const encoded = `${JSON.stringify(catalog, null, 2)}\n`;
|
|
75
|
+
decodeSessionCatalog(JSON.parse(encoded));
|
|
76
|
+
if (Buffer.byteLength(encoded, "utf8") > SESSION_CATALOG_BYTES) {
|
|
77
|
+
throw new Error("session catalogue data is invalid or unsupported");
|
|
78
|
+
}
|
|
79
|
+
return encoded;
|
|
80
|
+
}
|
|
81
|
+
export function decodeSessionCatalog(value) {
|
|
82
|
+
if (!record(value) || !keys(value, "createdAt,head,id,preview,resumeNodeId,turns,version,workspaceDigest"))
|
|
83
|
+
throw invalid();
|
|
84
|
+
const head = decodeHead(value["head"]);
|
|
85
|
+
const resumeNodeId = value["resumeNodeId"];
|
|
86
|
+
const turns = value["turns"];
|
|
87
|
+
const preview = value["preview"];
|
|
88
|
+
if (value["version"] !== CATALOG_SCHEMA ||
|
|
89
|
+
!identifier(value["id"]) ||
|
|
90
|
+
!digest(value["workspaceDigest"]) ||
|
|
91
|
+
!timestamp(value["createdAt"]) ||
|
|
92
|
+
!integer(resumeNodeId, 0) || resumeNodeId > head.nodeId ||
|
|
93
|
+
!integer(turns, 0) || turns > CONVERSATION_LIMITS.nodes || turns > resumeNodeId ||
|
|
94
|
+
typeof preview !== "string" || preview.length > PREVIEW_CODE_UNITS ||
|
|
95
|
+
(resumeNodeId === 0) !== (turns === 0) ||
|
|
96
|
+
(turns === 0 ? preview !== "" : preview === ""))
|
|
97
|
+
throw invalid();
|
|
98
|
+
return Object.freeze({
|
|
99
|
+
version: CATALOG_SCHEMA,
|
|
100
|
+
id: value["id"],
|
|
101
|
+
workspaceDigest: value["workspaceDigest"],
|
|
102
|
+
createdAt: value["createdAt"],
|
|
103
|
+
head,
|
|
104
|
+
resumeNodeId,
|
|
105
|
+
turns,
|
|
106
|
+
preview,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
export function catalogMatches(catalog, meta, head) {
|
|
110
|
+
return catalog.id === meta.id &&
|
|
111
|
+
catalog.workspaceDigest === meta.workspaceDigest &&
|
|
112
|
+
catalog.createdAt === meta.createdAt &&
|
|
113
|
+
sameSessionHead(catalog.head, head);
|
|
114
|
+
}
|
|
115
|
+
export function sameSessionHead(left, right) {
|
|
116
|
+
return left.version === right.version &&
|
|
117
|
+
left.sequence === right.sequence &&
|
|
118
|
+
left.nodeId === right.nodeId &&
|
|
119
|
+
left.parentId === right.parentId &&
|
|
120
|
+
left.revision === right.revision &&
|
|
121
|
+
left.updatedAt === right.updatedAt;
|
|
122
|
+
}
|
|
123
|
+
function own(value) {
|
|
124
|
+
encodeHead(value.head);
|
|
125
|
+
return decodeSessionCatalog(structuredClone(value));
|
|
126
|
+
}
|
|
127
|
+
function assertActiveHead(head, conversation) {
|
|
128
|
+
const active = conversation.activeNode;
|
|
129
|
+
if (active === undefined || active.id !== head.nodeId ||
|
|
130
|
+
active.parentId !== head.parentId || active.revision !== head.revision)
|
|
131
|
+
throw new Error("session catalogue does not match its conversation head");
|
|
132
|
+
}
|
|
133
|
+
function selectedTurnCount(conversation) {
|
|
134
|
+
let count = 0;
|
|
135
|
+
let id = conversation.activeNodeId;
|
|
136
|
+
while (id !== 0) {
|
|
137
|
+
count++;
|
|
138
|
+
id = conversation.node(id)?.parentId ?? 0;
|
|
139
|
+
}
|
|
140
|
+
return count;
|
|
141
|
+
}
|
|
142
|
+
function firstUserText(conversation) {
|
|
143
|
+
const path = [];
|
|
144
|
+
let id = conversation.activeNodeId;
|
|
145
|
+
while (id !== 0) {
|
|
146
|
+
const node = conversation.node(id);
|
|
147
|
+
if (node === undefined)
|
|
148
|
+
throw new Error("session catalogue path is incomplete");
|
|
149
|
+
path.push(node);
|
|
150
|
+
id = node.parentId;
|
|
151
|
+
}
|
|
152
|
+
for (let index = path.length - 1; index >= 0; index--) {
|
|
153
|
+
const preview = userTextInNode(path[index]);
|
|
154
|
+
if (preview !== undefined)
|
|
155
|
+
return preview;
|
|
156
|
+
}
|
|
157
|
+
return "Untitled session";
|
|
158
|
+
}
|
|
159
|
+
function firstUserTextInNode(node) {
|
|
160
|
+
return userTextInNode(node) ?? "Untitled session";
|
|
161
|
+
}
|
|
162
|
+
function userTextInNode(node) {
|
|
163
|
+
for (const message of node.messages) {
|
|
164
|
+
if (message.role !== "user")
|
|
165
|
+
continue;
|
|
166
|
+
const preview = textPreview(message.content.find((block) => block.kind === "text")?.text);
|
|
167
|
+
if (preview !== undefined)
|
|
168
|
+
return preview;
|
|
169
|
+
}
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
function textPreview(text) {
|
|
173
|
+
const normalized = text?.replace(/\s+/gu, " ").trim();
|
|
174
|
+
return normalized === undefined || normalized === ""
|
|
175
|
+
? undefined
|
|
176
|
+
: leadingText(normalized, PREVIEW_CODE_UNITS);
|
|
177
|
+
}
|
|
178
|
+
function keys(value, expected) {
|
|
179
|
+
return Object.keys(value).sort().join(",") === expected;
|
|
180
|
+
}
|
|
181
|
+
function record(value) {
|
|
182
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
183
|
+
}
|
|
184
|
+
function integer(value, minimum) {
|
|
185
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= minimum;
|
|
186
|
+
}
|
|
187
|
+
function identifier(value) {
|
|
188
|
+
return typeof value === "string" && /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(value);
|
|
189
|
+
}
|
|
190
|
+
function digest(value) {
|
|
191
|
+
return typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
192
|
+
}
|
|
193
|
+
function timestamp(value) {
|
|
194
|
+
return typeof value === "string" && value.length >= 20 && value.length <= 64 &&
|
|
195
|
+
Number.isFinite(Date.parse(value));
|
|
196
|
+
}
|
|
197
|
+
function invalid() {
|
|
198
|
+
return new Error("session catalogue data is invalid or unsupported");
|
|
199
|
+
}
|
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);
|
|
@@ -120,7 +101,8 @@ export class DurableSessionStore {
|
|
|
120
101
|
}
|
|
121
102
|
const nodes = stored.map((entry) => entry.node);
|
|
122
103
|
const conversation = ConversationTree.restore(nodes, head.nodeId);
|
|
123
|
-
|
|
104
|
+
const catalog = sessionCatalog(meta, head, conversation);
|
|
105
|
+
return Object.freeze({ meta, head, conversation, catalog });
|
|
124
106
|
}
|
|
125
107
|
async publish(conversation, claim) {
|
|
126
108
|
const active = conversation.activeNode;
|
|
@@ -147,6 +129,7 @@ export class DurableSessionStore {
|
|
|
147
129
|
revision: active.revision,
|
|
148
130
|
updatedAt: now,
|
|
149
131
|
});
|
|
132
|
+
const catalog = sessionCatalog(meta, head, conversation);
|
|
150
133
|
try {
|
|
151
134
|
await makePrivateDirectory(temporary);
|
|
152
135
|
const nodes = path.join(temporary, "nodes");
|
|
@@ -157,6 +140,7 @@ export class DurableSessionStore {
|
|
|
157
140
|
}
|
|
158
141
|
await atomicWrite(path.join(temporary, "meta.json"), encodeMeta(meta), { mode: FILE_MODE });
|
|
159
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 });
|
|
160
144
|
if (token !== undefined) {
|
|
161
145
|
await atomicWrite(path.join(temporary, "active"), token, { mode: FILE_MODE });
|
|
162
146
|
}
|
|
@@ -166,7 +150,7 @@ export class DurableSessionStore {
|
|
|
166
150
|
await removeTemporaryDirectory(temporary, this.#bucket);
|
|
167
151
|
throw error;
|
|
168
152
|
}
|
|
169
|
-
const snapshot = Object.freeze({ meta, head, conversation });
|
|
153
|
+
const snapshot = Object.freeze({ meta, head, conversation, catalog });
|
|
170
154
|
if (token === undefined)
|
|
171
155
|
return snapshot;
|
|
172
156
|
const lease = sessionLease(id, path.join(target, "active"), token);
|
|
@@ -184,7 +168,7 @@ export class DurableSessionStore {
|
|
|
184
168
|
};
|
|
185
169
|
await validateNodesDirectory();
|
|
186
170
|
const currentHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes));
|
|
187
|
-
if (!
|
|
171
|
+
if (!sameSessionHead(currentHead, previous.head)) {
|
|
188
172
|
throw new Error("session head changed after its verified snapshot");
|
|
189
173
|
}
|
|
190
174
|
const active = conversation.activeNode;
|
|
@@ -216,12 +200,19 @@ export class DurableSessionStore {
|
|
|
216
200
|
revision: active.revision,
|
|
217
201
|
updatedAt: now,
|
|
218
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) });
|
|
219
206
|
await atomicWrite(path.join(nodesDirectory, nodeName(active.id)), encodeNode(active, head.sequence, now), { mode: FILE_MODE, validate: async () => validateNodesDirectory() });
|
|
220
207
|
await atomicWrite(path.join(directory, "head.json"), encodeHead(head), {
|
|
221
208
|
mode: FILE_MODE,
|
|
222
209
|
validate: async () => assertDirectory(directory),
|
|
223
210
|
});
|
|
224
|
-
|
|
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 });
|
|
225
216
|
}
|
|
226
217
|
async claim(id) {
|
|
227
218
|
assertSessionId(id);
|
|
@@ -265,6 +256,74 @@ export class DurableSessionStore {
|
|
|
265
256
|
const owner = await leaseOwner(path.join(this.#sessionDirectory(id), "active"));
|
|
266
257
|
return owner !== undefined && pidIsAlive(owner.pid);
|
|
267
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
|
+
}
|
|
268
327
|
}
|
|
269
328
|
async function catalogNames(directory) {
|
|
270
329
|
try {
|
|
@@ -288,6 +347,26 @@ async function catalogNames(directory) {
|
|
|
288
347
|
}
|
|
289
348
|
return names.sort((left, right) => right.localeCompare(left));
|
|
290
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
|
+
}
|
|
291
370
|
function assertSharedNodes(previous, next, replacedId) {
|
|
292
371
|
for (const node of previous.nodes) {
|
|
293
372
|
if (node.id === replacedId)
|
|
@@ -301,6 +380,7 @@ function assertSharedNodes(previous, next, replacedId) {
|
|
|
301
380
|
function assertSnapshot(snapshot, workspaceRoot, workspaceDigest) {
|
|
302
381
|
encodeMeta(snapshot.meta);
|
|
303
382
|
encodeHead(snapshot.head);
|
|
383
|
+
encodeSessionCatalog(snapshot.catalog);
|
|
304
384
|
assertSessionId(snapshot.meta.id);
|
|
305
385
|
if (snapshot.meta.workspaceDigest !== workspaceDigest ||
|
|
306
386
|
workspaceKey(snapshot.meta.workspaceRoot) !== workspaceKey(workspaceRoot))
|
|
@@ -312,14 +392,9 @@ function assertSnapshot(snapshot, workspaceRoot, workspaceDigest) {
|
|
|
312
392
|
active.revision !== snapshot.head.revision ||
|
|
313
393
|
snapshot.head.sequence < snapshot.conversation.nodes.length)
|
|
314
394
|
throw new Error("session snapshot does not match its verified head");
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
left.sequence === right.sequence &&
|
|
319
|
-
left.nodeId === right.nodeId &&
|
|
320
|
-
left.parentId === right.parentId &&
|
|
321
|
-
left.revision === right.revision &&
|
|
322
|
-
left.updatedAt === right.updatedAt;
|
|
395
|
+
if (!catalogMatches(snapshot.catalog, snapshot.meta, snapshot.head)) {
|
|
396
|
+
throw new Error("session snapshot does not match its verified catalogue");
|
|
397
|
+
}
|
|
323
398
|
}
|
|
324
399
|
async function assertMissingNode(file) {
|
|
325
400
|
try {
|
|
@@ -393,17 +468,6 @@ async function makePrivateDirectory(directory) {
|
|
|
393
468
|
if (process.platform !== "win32")
|
|
394
469
|
await chmod(directory, DIRECTORY_MODE);
|
|
395
470
|
}
|
|
396
|
-
async function directoryEntries(directory) {
|
|
397
|
-
try {
|
|
398
|
-
await assertDirectory(directory);
|
|
399
|
-
return await readdir(directory, { withFileTypes: true });
|
|
400
|
-
}
|
|
401
|
-
catch (error) {
|
|
402
|
-
if (error.code === "ENOENT")
|
|
403
|
-
return [];
|
|
404
|
-
throw error;
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
471
|
async function removeTemporaryDirectory(directory, bucket) {
|
|
408
472
|
const relative = path.relative(bucket, directory);
|
|
409
473
|
if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative) ||
|
|
@@ -411,26 +475,6 @@ async function removeTemporaryDirectory(directory, bucket) {
|
|
|
411
475
|
throw new Error("refusing to remove an unverified session directory");
|
|
412
476
|
await rm(directory, { recursive: true, force: true });
|
|
413
477
|
}
|
|
414
|
-
function selectedTurnCount(conversation) {
|
|
415
|
-
let count = 0;
|
|
416
|
-
let id = conversation.activeNodeId;
|
|
417
|
-
while (id !== 0) {
|
|
418
|
-
count++;
|
|
419
|
-
id = conversation.node(id)?.parentId ?? 0;
|
|
420
|
-
}
|
|
421
|
-
return count;
|
|
422
|
-
}
|
|
423
|
-
function firstUserText(conversation) {
|
|
424
|
-
for (const message of conversation.history) {
|
|
425
|
-
if (message.role !== "user")
|
|
426
|
-
continue;
|
|
427
|
-
const text = message.content.find((block) => block.kind === "text")?.text
|
|
428
|
-
.replace(/\s+/gu, " ").trim();
|
|
429
|
-
if (text !== undefined && text !== "")
|
|
430
|
-
return leadingText(text, 160);
|
|
431
|
-
}
|
|
432
|
-
return "Untitled session";
|
|
433
|
-
}
|
|
434
478
|
function nodeName(id) {
|
|
435
479
|
return `${String(id).padStart(6, "0")}.json`;
|
|
436
480
|
}
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@giovannijecha/jecode",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.4",
|
|
4
4
|
"description": "An owned coding agent with zero external runtime dependencies.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"bin/",
|
|
27
27
|
"dist/",
|
|
28
28
|
"assets/jeco-256.png",
|
|
29
|
+
"assets/wordmark-steel.svg",
|
|
29
30
|
"LICENSE",
|
|
30
31
|
"README.md"
|
|
31
32
|
],
|