@giovannijecha/jecode 0.8.3 → 0.8.5

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.
Files changed (55) hide show
  1. package/README.md +11 -8
  2. package/assets/wordmark-steel.svg +3 -0
  3. package/dist/accounts.js +47 -10
  4. package/dist/atomic.js +24 -14
  5. package/dist/batch.js +49 -4
  6. package/dist/bounded-file.js +212 -0
  7. package/dist/commands.js +2 -0
  8. package/dist/config.js +8 -4
  9. package/dist/context/automatic.js +35 -0
  10. package/dist/context/compactor.js +32 -2
  11. package/dist/context/manual.js +20 -3
  12. package/dist/context/request-projection.js +130 -0
  13. package/dist/controller-request.js +33 -11
  14. package/dist/credential-commands.js +22 -6
  15. package/dist/credentials.js +56 -18
  16. package/dist/directory-anchor.js +91 -0
  17. package/dist/file-identity.js +12 -0
  18. package/dist/model-command.js +5 -4
  19. package/dist/openai-account-command.js +13 -2
  20. package/dist/process-lease.js +329 -0
  21. package/dist/provider-commands.js +53 -10
  22. package/dist/provider-errors.js +94 -3
  23. package/dist/provider-label.js +13 -0
  24. package/dist/providers/anthropic-stream.js +4 -1
  25. package/dist/providers/anthropic-wire.js +8 -3
  26. package/dist/providers/anthropic.js +39 -19
  27. package/dist/providers/catalog.js +4 -4
  28. package/dist/providers/failure.js +181 -0
  29. package/dist/providers/http.js +86 -57
  30. package/dist/providers/ollama-stream.js +5 -1
  31. package/dist/providers/ollama.js +31 -19
  32. package/dist/providers/openai-codex.js +67 -41
  33. package/dist/providers/openai-stream.js +69 -9
  34. package/dist/providers/openai.js +51 -24
  35. package/dist/providers/sse.js +89 -17
  36. package/dist/request-identity.js +32 -0
  37. package/dist/sessions/catalog.js +199 -0
  38. package/dist/sessions/lease.js +132 -49
  39. package/dist/sessions/runtime.js +15 -8
  40. package/dist/sessions/store.js +451 -183
  41. package/dist/settings.js +62 -10
  42. package/dist/stable-directory.js +148 -0
  43. package/dist/store-lock.js +68 -84
  44. package/dist/tools/args.js +2 -2
  45. package/dist/tools/fs.js +124 -102
  46. package/dist/tools/search.js +81 -107
  47. package/dist/tools/text-boundary.js +7 -33
  48. package/dist/tui/app-workflows.js +32 -4
  49. package/dist/tui/components/footer.js +1 -1
  50. package/dist/tui/feedback.js +4 -0
  51. package/dist/tui/session-view.js +7 -2
  52. package/dist/tui/workspace.js +21 -7
  53. package/dist/user-store.js +23 -31
  54. package/package.json +4 -4
  55. package/dist/tools/ripgrep.js +0 -230
@@ -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
+ }
@@ -1,83 +1,166 @@
1
- // A tiny, bounded process lease for one durable conversation.
2
- import { randomUUID } from "node:crypto";
3
- import { lstat, open, unlink } from "node:fs/promises";
4
- const MAX_LEASE_BYTES = 256;
1
+ // A tiny, generation-safe process lease for one durable conversation.
2
+ import { lstat, unlink } from "node:fs/promises";
3
+ import { BoundedFileError, readBoundedText, stableFileExpectation, } from "../bounded-file.js";
4
+ import { fileIdentity, sameFileIdentity } from "../file-identity.js";
5
+ import { createProcessLeaseDirectory, inspectProcessLease, pidIsAlive, ProcessLeaseError, processLease, processLeaseToken, removeProcessLease, tryAcquireProcessLease, } from "../process-lease.js";
6
+ const LEGACY_LEASE_BYTES = 256;
7
+ const sessionLeaseScopes = new WeakMap();
5
8
  export function leaseToken() {
6
- return `${process.pid}:${randomUUID()}`;
9
+ return processLeaseToken();
7
10
  }
8
- export function sessionLease(id, file, token) {
11
+ export async function createLeaseDirectory(directory, token) {
12
+ return createProcessLeaseDirectory(directory, token);
13
+ }
14
+ export async function claimLeaseDirectory(directory, token) {
15
+ try {
16
+ return await tryAcquireProcessLease(directory, token, { staleMs: 0, setupGraceMs: 1_000 });
17
+ }
18
+ catch (error) {
19
+ if (error instanceof ProcessLeaseError)
20
+ throw unsafeLease();
21
+ throw error;
22
+ }
23
+ }
24
+ export function leaseFromGeneration(directory, generation) {
25
+ return processLease(directory, generation);
26
+ }
27
+ export function sessionLease(id, scope, lease) {
9
28
  let closed = false;
10
- return Object.freeze({
29
+ const owned = Object.freeze({
11
30
  id,
12
31
  assertOwned: async () => {
13
32
  if (closed)
14
33
  throw new Error("session lease is closed");
15
- const current = await readLease(file);
16
- if (current !== token)
34
+ try {
35
+ await lease.assertOwned();
36
+ }
37
+ catch {
17
38
  throw new Error("session lease is no longer owned by this process");
39
+ }
18
40
  },
19
41
  close: async () => {
20
42
  if (closed)
21
43
  return;
44
+ const removed = await lease.release();
45
+ if (!removed)
46
+ throw new Error("session lease is no longer owned by this process");
22
47
  closed = true;
23
- try {
24
- await removeLease(file, token);
25
- }
26
- catch {
27
- // A recovered, replaced, or already-closed lease is no longer ours.
28
- }
29
48
  },
30
49
  });
50
+ sessionLeaseScopes.set(owned, { id, scope });
51
+ return owned;
52
+ }
53
+ export function sessionLeaseOwns(lease, id, scope) {
54
+ const owner = sessionLeaseScopes.get(lease);
55
+ return owner?.id === id && owner.scope === scope;
31
56
  }
32
- export async function leaseOwner(file) {
57
+ export async function leaseOwner(directory, hooks = {}) {
33
58
  try {
34
- const token = await readLease(file);
35
- const match = /^([1-9]\d*):/.exec(token.trim());
36
- if (match === null)
37
- return { pid: 0, token };
38
- const pid = Number(match[1]);
39
- return { pid: Number.isSafeInteger(pid) ? pid : 0, token };
59
+ const owner = await inspectProcessLease(directory);
60
+ return owner === undefined
61
+ ? undefined
62
+ : { pid: owner.pid, token: owner.token, legacy: false };
40
63
  }
41
64
  catch (error) {
42
- if (error.code === "ENOENT")
43
- return undefined;
65
+ if (error instanceof ProcessLeaseError) {
66
+ let legacy;
67
+ try {
68
+ legacy = await legacyLeaseOwner(directory, hooks);
69
+ }
70
+ catch (legacyError) {
71
+ if (unsafeLegacyRace(legacyError))
72
+ throw unsafeLease();
73
+ throw legacyError;
74
+ }
75
+ if (legacy !== undefined)
76
+ return legacy;
77
+ throw unsafeLease();
78
+ }
44
79
  throw error;
45
80
  }
46
81
  }
47
- export async function removeLease(file, token) {
48
- const current = await readLease(file).catch(() => undefined);
49
- if (current === token)
50
- await unlink(file).catch(() => undefined);
82
+ export async function removeLease(directory, token) {
83
+ try {
84
+ return await removeProcessLease(directory, token);
85
+ }
86
+ catch (error) {
87
+ if (error instanceof ProcessLeaseError)
88
+ throw unsafeLease();
89
+ throw error;
90
+ }
51
91
  }
52
- export function pidIsAlive(pid) {
53
- if (!Number.isSafeInteger(pid) || pid < 1 || pid > 0x7fff_ffff)
92
+ /** Remove a legacy fixed-file marker only while its session is exclusively owned. */
93
+ export async function removeLegacyLeaseExclusive(file, token, owner) {
94
+ if (!sessionLeaseScopes.has(owner)) {
95
+ throw new Error("legacy lease migration requires a Jecode session lease");
96
+ }
97
+ await owner.assertOwned();
98
+ let before;
99
+ try {
100
+ before = await lstat(file, { bigint: true });
101
+ }
102
+ catch (error) {
103
+ if (error.code === "ENOENT")
104
+ return true;
105
+ throw error;
106
+ }
107
+ if (before.isSymbolicLink() || !before.isFile() || before.size > BigInt(LEGACY_LEASE_BYTES)) {
54
108
  return false;
109
+ }
55
110
  try {
56
- process.kill(pid, 0);
111
+ const current = await readBoundedText(file, LEGACY_LEASE_BYTES, {
112
+ label: "legacy session lease",
113
+ expected: stableFileExpectation(before),
114
+ });
115
+ if (current !== token)
116
+ return false;
117
+ const after = await lstat(file, { bigint: true });
118
+ if (!sameFileIdentity(fileIdentity(before), fileIdentity(after)))
119
+ return false;
120
+ await owner.assertOwned();
121
+ await unlink(file);
57
122
  return true;
58
123
  }
59
124
  catch (error) {
60
- return error.code !== "ESRCH";
125
+ if (unsafeLegacyRace(error))
126
+ return false;
127
+ throw error;
61
128
  }
62
129
  }
63
- async function readLease(file) {
64
- const before = await lstat(file);
65
- if (before.isSymbolicLink() || !before.isFile() || before.size > MAX_LEASE_BYTES) {
66
- throw new Error("session lease is unsafe or too large");
67
- }
68
- const handle = await open(file, "r");
130
+ export { pidIsAlive };
131
+ function unsafeLease() {
132
+ return new Error("session lease is unsafe or too large, or invalid");
133
+ }
134
+ async function legacyLeaseOwner(file, hooks) {
135
+ let details;
69
136
  try {
70
- const opened = await handle.stat();
71
- if (!opened.isFile() || opened.size > MAX_LEASE_BYTES ||
72
- opened.dev !== before.dev || opened.ino !== before.ino)
73
- throw new Error("session lease changed while opening");
74
- const bytes = Buffer.alloc(MAX_LEASE_BYTES + 1);
75
- const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
76
- if (bytesRead > MAX_LEASE_BYTES)
77
- throw new Error("session lease is too large");
78
- return bytes.subarray(0, bytesRead).toString("utf8");
137
+ details = await lstat(file, { bigint: true });
79
138
  }
80
- finally {
81
- await handle.close();
139
+ catch (error) {
140
+ if (error.code === "ENOENT")
141
+ return undefined;
142
+ throw error;
143
+ }
144
+ if (details.isSymbolicLink() || !details.isFile() || details.size > BigInt(LEGACY_LEASE_BYTES)) {
145
+ return undefined;
82
146
  }
147
+ await hooks.afterLegacyStat?.();
148
+ const token = await readBoundedText(file, LEGACY_LEASE_BYTES, {
149
+ label: "legacy session lease",
150
+ expected: stableFileExpectation(details),
151
+ });
152
+ const match = /^([1-9]\d*):/.exec(token.trim());
153
+ const pid = match === null ? 0 : Number(match[1]);
154
+ return {
155
+ pid: Number.isSafeInteger(pid) && pid <= 0x7fff_ffff ? pid : 0,
156
+ token,
157
+ legacy: true,
158
+ };
159
+ }
160
+ function unsafeLegacyRace(error) {
161
+ if (error instanceof BoundedFileError)
162
+ return true;
163
+ const code = error.code;
164
+ return code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP" ||
165
+ code === "EISDIR" || code === "ENXIO";
83
166
  }
@@ -3,36 +3,38 @@
3
3
  // One logical conversation keeps one stable session id across every resume.
4
4
  // New turns advance that session's tree head; /new is the boundary that starts
5
5
  // another durable session.
6
- import { DurableSessionStore } from "./store.js";
6
+ import { DurableSessionStore, reserveSessionId } from "./store.js";
7
7
  export class SessionPersistence {
8
8
  #store;
9
9
  #sessionId;
10
+ #conversationId;
10
11
  #lease;
11
12
  #snapshot;
12
13
  #failure;
13
- constructor(store, sessionId, lease, snapshot) {
14
+ constructor(store, sessionId, lease, snapshot, conversationId = sessionId ?? reserveSessionId()) {
14
15
  this.#store = store;
15
16
  this.#sessionId = sessionId;
17
+ this.#conversationId = conversationId;
16
18
  this.#lease = lease;
17
19
  this.#snapshot = snapshot;
18
20
  }
19
21
  static fresh(store) {
20
- return new SessionPersistence(store, null);
22
+ return new SessionPersistence(store, null, undefined, undefined, reserveSessionId());
21
23
  }
22
24
  static async resume(store, id) {
23
25
  const lease = await store.claim(id);
24
26
  try {
25
- const snapshot = await store.load(id);
27
+ const snapshot = await store.load(id, lease);
26
28
  const conversation = snapshot.conversation.latestResumable();
27
29
  if (conversation === undefined)
28
30
  throw new Error("session has no resumable turn");
29
31
  return Object.freeze({
30
32
  conversation,
31
- persistence: new SessionPersistence(store, id, lease, snapshot),
33
+ persistence: new SessionPersistence(store, id, lease, snapshot, id),
32
34
  });
33
35
  }
34
36
  catch (error) {
35
- await lease.close();
37
+ await lease.close().catch(() => undefined);
36
38
  throw error;
37
39
  }
38
40
  }
@@ -45,12 +47,16 @@ export class SessionPersistence {
45
47
  get sessionId() {
46
48
  return this.#sessionId;
47
49
  }
50
+ /** Reserved before publication so provider cache identity survives resume. */
51
+ get conversationId() {
52
+ return this.#conversationId;
53
+ }
48
54
  async checkpoint(conversation) {
49
55
  if (this.#failure !== undefined)
50
56
  throw this.#failure;
51
57
  try {
52
58
  if (this.#sessionId === null) {
53
- const published = await this.#store.publish(conversation, true);
59
+ const published = await this.#store.publish(conversation, true, this.#conversationId);
54
60
  this.#lease = published.lease;
55
61
  this.#sessionId = published.meta.id;
56
62
  this.#snapshot = published;
@@ -60,7 +66,7 @@ export class SessionPersistence {
60
66
  throw new Error("session persistence has no verified owner snapshot");
61
67
  }
62
68
  await this.#lease.assertOwned();
63
- this.#snapshot = await this.#store.checkpoint(this.#snapshot, conversation);
69
+ this.#snapshot = await this.#store.checkpoint(this.#snapshot, conversation, this.#lease);
64
70
  }
65
71
  catch (error) {
66
72
  this.#failure = error;
@@ -71,6 +77,7 @@ export class SessionPersistence {
71
77
  await this.#lease?.close();
72
78
  this.#lease = undefined;
73
79
  this.#sessionId = null;
80
+ this.#conversationId = reserveSessionId();
74
81
  this.#snapshot = undefined;
75
82
  this.#failure = undefined;
76
83
  }