@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
@@ -4,8 +4,9 @@ import { openAICodexAccount } from "../accounts.js";
4
4
  import { openAIAuthorization } from "../openai-account.js";
5
5
  import { applicationVersion } from "../version.js";
6
6
  import { EFFORTS, isEffort, requireSupportedEffort } from "../effort.js";
7
+ import { isRetryableGenerationFailure, isRetryableReadFailure, throwProviderError, } from "./failure.js";
7
8
  import { getJson, postSse } from "./http.js";
8
- import { assembleOpenAI } from "./openai-stream.js";
9
+ import { assembleOpenAI, openAIStreamProgress } from "./openai-stream.js";
9
10
  import { fromWireResponse, stopNotice, toWireItems, toWireTool, } from "./openai-wire.js";
10
11
  const ID = "openai-codex";
11
12
  const BASE = "https://chatgpt.com/backend-api/codex";
@@ -13,7 +14,6 @@ const BASE = "https://chatgpt.com/backend-api/codex";
13
14
  // own catalogue updater uses this sentinel to request the complete current
14
15
  // manifest; Jecode then keeps only entries explicitly visible in that manifest.
15
16
  const CATALOG_COMPATIBILITY_VERSION = "99.99.99";
16
- const SESSION_ID = randomUUID();
17
17
  const MAX_CATALOG_ITEMS = 4_000;
18
18
  const MAX_MODELS = 1_000;
19
19
  const MAX_MODEL_CHARS = 256;
@@ -29,60 +29,84 @@ export const openaiCodex = {
29
29
  },
30
30
  location: () => "cloud",
31
31
  async models(signal, onStatus) {
32
- const catalog = await loadCatalog(signal, onStatus);
33
- rememberCatalog(catalog);
34
- return catalog.ids;
32
+ try {
33
+ const catalog = await loadCatalog(signal, onStatus);
34
+ rememberCatalog(catalog);
35
+ return catalog.ids;
36
+ }
37
+ catch (error) {
38
+ throwProviderError(ID, signal, error);
39
+ }
35
40
  },
36
41
  async efforts(model, signal, onStatus) {
37
42
  const cached = effortByModel.get(model);
38
43
  if (cached !== undefined)
39
44
  return cached;
40
- const catalog = await loadCatalog(signal, onStatus);
41
- rememberCatalog(catalog);
42
- return effortByModel.get(model) ?? fallbackEfforts(model);
45
+ try {
46
+ const catalog = await loadCatalog(signal, onStatus);
47
+ rememberCatalog(catalog);
48
+ return effortByModel.get(model) ?? fallbackEfforts(model);
49
+ }
50
+ catch (error) {
51
+ throwProviderError(ID, signal, error);
52
+ }
43
53
  },
44
54
  async contextWindow(model, signal, onStatus) {
45
55
  if (contextByModel.has(model))
46
56
  return contextByModel.get(model);
47
- const catalog = await loadCatalog(signal, onStatus);
48
- rememberCatalog(catalog);
49
- const context = contextByModel.get(model);
50
- if (!contextByModel.has(model))
51
- contextByModel.set(model, undefined);
52
- return context;
57
+ try {
58
+ const catalog = await loadCatalog(signal, onStatus);
59
+ rememberCatalog(catalog);
60
+ const context = contextByModel.get(model);
61
+ if (!contextByModel.has(model))
62
+ contextByModel.set(model, undefined);
63
+ return context;
64
+ }
65
+ catch (error) {
66
+ throwProviderError(ID, signal, error);
67
+ }
53
68
  },
54
69
  async send(req) {
55
70
  const efforts = effortByModel.get(req.model) ?? fallbackEfforts(req.model);
56
71
  const effort = requireSupportedEffort(req.model, req.effort, efforts);
57
- return withAuthorization(async (authorization) => {
58
- const events = await postSse(`${BASE}/responses`, {
59
- ...headers(authorization, randomUUID()),
60
- "openai-beta": "responses=experimental",
61
- }, {
62
- model: req.model,
63
- store: false,
64
- stream: true,
65
- instructions: req.system,
66
- input: req.messages.flatMap((message) => toWireItems(message, ID)),
67
- tools: req.tools.map(toWireTool),
68
- tool_choice: "auto",
69
- parallel_tool_calls: true,
70
- reasoning: { effort, summary: "auto" },
71
- text: { verbosity: "low" },
72
- include: ["reasoning.encrypted_content"],
73
- prompt_cache_key: SESSION_ID,
74
- }, req.maxTokens, req.signal, req.onStatus);
75
- const data = await assembleOpenAI(events, req.onStream);
76
- const notice = stopNotice(data);
77
- if (notice !== undefined)
78
- req.onStream?.({ kind: "text", text: `\n${notice}` });
79
- return fromWireResponse(data, ID);
80
- }, req.signal, req.onStatus);
72
+ const sessionId = req.identity?.conversationId ?? randomUUID();
73
+ const cacheKey = req.identity?.cacheKey ?? sessionId;
74
+ try {
75
+ return await withAuthorization(async (authorization) => {
76
+ const events = await postSse(`${BASE}/responses`, {
77
+ ...headers(authorization, sessionId, randomUUID()),
78
+ "openai-beta": "responses=experimental",
79
+ }, {
80
+ model: req.model,
81
+ store: false,
82
+ stream: true,
83
+ instructions: req.system,
84
+ input: req.messages.flatMap((message) => toWireItems(message, ID)),
85
+ tools: req.tools.map(toWireTool),
86
+ tool_choice: "auto",
87
+ parallel_tool_calls: true,
88
+ reasoning: { effort, summary: "auto" },
89
+ text: { verbosity: "low" },
90
+ include: ["reasoning.encrypted_content"],
91
+ ...(req.identity?.purpose === "compaction"
92
+ ? {}
93
+ : { prompt_cache_key: cacheKey }),
94
+ }, req.maxTokens, req.signal, req.onStatus, openAIStreamProgress, (error) => isRetryableGenerationFailure(ID, error));
95
+ const data = await assembleOpenAI(events, req.onStream, req.onStatus);
96
+ const notice = stopNotice(data);
97
+ if (notice !== undefined)
98
+ req.onStream?.({ kind: "text", text: `\n${notice}` });
99
+ return fromWireResponse(data, ID);
100
+ }, req.signal, req.onStatus);
101
+ }
102
+ catch (error) {
103
+ throwProviderError(ID, req.signal, error);
104
+ }
81
105
  },
82
106
  };
83
107
  async function loadCatalog(signal, onStatus) {
84
108
  return withAuthorization(async (authorization) => {
85
- const body = await getJson(`${BASE}/models?client_version=${CATALOG_COMPATIBILITY_VERSION}`, headers(authorization, randomUUID()), signal, onStatus);
109
+ const body = await getJson(`${BASE}/models?client_version=${CATALOG_COMPATIBILITY_VERSION}`, headers(authorization, randomUUID(), randomUUID()), signal, onStatus, (error) => isRetryableReadFailure(ID, error));
86
110
  return modelCatalog(body);
87
111
  }, signal, onStatus);
88
112
  }
@@ -102,14 +126,14 @@ async function withAuthorization(operation, signal, onStatus) {
102
126
  return operation(authorization);
103
127
  }
104
128
  }
105
- function headers(authorization, requestId) {
129
+ function headers(authorization, sessionId, requestId) {
106
130
  const version = applicationVersion();
107
131
  return {
108
132
  authorization: `Bearer ${authorization.accessToken}`,
109
133
  "chatgpt-account-id": authorization.accountId,
110
134
  originator: "jecode",
111
135
  "user-agent": `jecode/${version} (${process.platform}; ${process.arch})`,
112
- "session-id": SESSION_ID,
136
+ "session-id": sessionId,
113
137
  "x-client-request-id": requestId,
114
138
  };
115
139
  }
@@ -185,6 +209,8 @@ function reasoningLevels(entry, model) {
185
209
  return efforts.length === 0 ? fallbackEfforts(model) : efforts;
186
210
  }
187
211
  function fallbackEfforts(model) {
212
+ if (/^gpt-6-astra(?:-|$)/.test(model))
213
+ return EFFORTS;
188
214
  if (/^gpt-5\.6-(?:sol|terra|luna)(?:-|$)/.test(model))
189
215
  return EFFORTS;
190
216
  return XHIGH_EFFORTS;
@@ -4,40 +4,74 @@
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
+ import { providerWireError } from "./failure.js";
8
+ export async function assembleOpenAI(events, onStream, onStatus) {
8
9
  const items = [];
9
10
  const announcedTools = { identities: new Set(), anonymous: false };
10
11
  let refusal = false;
12
+ let activity;
13
+ const status = (next) => {
14
+ if (activity === next)
15
+ return;
16
+ activity = next;
17
+ onStatus?.(next);
18
+ };
11
19
  for await (const raw of events) {
12
20
  const event = raw;
13
21
  switch (event.type) {
22
+ case "response.created":
23
+ case "response.in_progress":
24
+ if (activity === undefined)
25
+ status("Working");
26
+ break;
14
27
  case "response.output_text.delta":
15
- if (typeof event.delta === "string")
28
+ if (typeof event.delta === "string") {
29
+ status("Responding");
16
30
  onStream?.({ kind: "text", text: event.delta });
31
+ }
17
32
  break;
18
33
  case "response.refusal.delta":
19
34
  if (typeof event.delta === "string") {
35
+ status("Responding");
20
36
  onStream?.({ kind: "text", text: `${refusal ? "" : "[refused] "}${event.delta}` });
21
37
  refusal = true;
22
38
  }
23
39
  break;
24
40
  case "response.reasoning_summary_text.delta":
25
- if (typeof event.delta === "string")
41
+ if (typeof event.delta === "string") {
42
+ status("Thinking");
26
43
  onStream?.({ kind: "thinking", text: event.delta });
44
+ }
45
+ break;
46
+ case "response.reasoning_summary_part.added":
47
+ status("Thinking");
48
+ break;
49
+ case "response.reasoning_summary_text.done":
50
+ case "response.reasoning_summary_part.done":
51
+ status("Working");
27
52
  break;
28
53
  case "response.output_item.added":
29
54
  if (isFunctionCall(event.item)) {
30
- announceTool(event, event.item, announcedTools, onStream);
55
+ announceTool(event, event.item, announcedTools, onStream, status);
56
+ }
57
+ else if (itemType(event.item) === "reasoning") {
58
+ status("Thinking");
59
+ }
60
+ else if (itemType(event.item) === "message") {
61
+ status("Responding");
31
62
  }
32
63
  break;
33
64
  case "response.function_call_arguments.delta":
34
65
  case "response.function_call_arguments.done":
35
- announceTool(event, undefined, announcedTools, onStream);
66
+ announceTool(event, undefined, announcedTools, onStream, status);
36
67
  break;
37
68
  case "response.output_item.done":
38
69
  if (event.item !== undefined) {
39
70
  if (isFunctionCall(event.item)) {
40
- announceTool(event, event.item, announcedTools, onStream);
71
+ announceTool(event, event.item, announcedTools, onStream, status);
72
+ }
73
+ else if (itemType(event.item) === "reasoning") {
74
+ status("Working");
41
75
  }
42
76
  items.push(event.item);
43
77
  }
@@ -52,21 +86,46 @@ export async function assembleOpenAI(events, onStream) {
52
86
  };
53
87
  case "response.failed": {
54
88
  const response = event.response;
55
- throw new Error(`openai stream error: ${response?.error?.message ?? "unspecified"}`);
89
+ throw providerWireError("openai stream error", response?.error?.message, {
90
+ code: response?.error?.code,
91
+ type: response?.error?.type,
92
+ });
56
93
  }
57
94
  case "error":
58
- throw new Error(`openai stream error: ${event.error?.message ?? event.message ?? "unspecified"}`);
95
+ throw providerWireError("openai stream error", event.error?.message ?? event.message, { code: event.error?.code, type: event.error?.type });
59
96
  default:
60
97
  break;
61
98
  }
62
99
  }
63
100
  throw new Error("openai stream ended before a terminal response event");
64
101
  }
102
+ /** State-only keepalives prove transport liveness, not forward model progress. */
103
+ export function openAIStreamProgress(raw) {
104
+ if (typeof raw !== "object" || raw === null)
105
+ return false;
106
+ const type = raw["type"];
107
+ if (typeof type !== "string")
108
+ return false;
109
+ if (type === "response.created")
110
+ return true;
111
+ if (type === "response.done" ||
112
+ type === "response.completed" ||
113
+ type === "response.incomplete" ||
114
+ type === "response.failed" ||
115
+ type === "error")
116
+ return true;
117
+ return /\.(?:added|delta|done)$/u.test(type);
118
+ }
65
119
  function isFunctionCall(item) {
66
120
  return typeof item === "object" && item !== null &&
67
121
  item["type"] === "function_call";
68
122
  }
69
- function announceTool(event, item, announced, onStream) {
123
+ function itemType(item) {
124
+ return typeof item === "object" && item !== null
125
+ ? item["type"]
126
+ : undefined;
127
+ }
128
+ function announceTool(event, item, announced, onStream, onStatus) {
70
129
  const identities = toolIdentities(event, item);
71
130
  if (identities.length === 0) {
72
131
  if (announced.anonymous)
@@ -82,6 +141,7 @@ function announceTool(event, item, announced, onStream) {
82
141
  }
83
142
  const rawName = item?.name ?? event.name;
84
143
  const name = typeof rawName === "string" && rawName !== "" ? rawName : undefined;
144
+ onStatus?.(`Preparing ${name ?? "tool"}`);
85
145
  onStream?.({ kind: "tool", ...(name === undefined ? {} : { name }) });
86
146
  }
87
147
  function toolIdentities(event, item) {
@@ -2,15 +2,20 @@
2
2
  //
3
3
  // Responses wire contract verified against the official API reference on
4
4
  // 2026-08-29. Keep final response events authoritative over display deltas.
5
+ import { randomUUID } from "node:crypto";
6
+ import { applicationVersion } from "../version.js";
5
7
  import { postSse } from "./http.js";
6
8
  import { listModels } from "./catalog.js";
7
9
  import { keyFor } from "../credentials.js";
8
10
  import { EFFORTS, requireSupportedEffort } from "../effort.js";
9
- import { assembleOpenAI } from "./openai-stream.js";
11
+ import { isRetryableGenerationFailure, isRetryableReadFailure, throwProviderError, } from "./failure.js";
12
+ import { assembleOpenAI, openAIStreamProgress } from "./openai-stream.js";
10
13
  import { fromWireResponse, stopNotice, toWireItems, toWireTool, } from "./openai-wire.js";
11
14
  const ENDPOINT = "https://api.openai.com/v1/responses";
12
15
  const MODELS = "https://api.openai.com/v1/models";
13
16
  const KEY = "OPENAI_API_KEY";
17
+ const ID = "openai";
18
+ const ASTRA_MODEL = /^gpt-6-astra(?:-|$)/;
14
19
  const RESPONSES_REASONING_MODEL = /^(?:gpt-5(?:[.-]|$)|o(?:1|3|4)(?:[.-]|$)|codex-mini(?:[.-]|$))/;
15
20
  // Jecode's transport always streams and always declares local tools. Hide
16
21
  // catalog entries that cannot satisfy either half of that contract.
@@ -20,11 +25,14 @@ const XHIGH_EFFORTS = ["low", "medium", "high", "xhigh"];
20
25
  const PRO_EFFORTS = ["medium", "high", "xhigh"];
21
26
  const HIGH_ONLY_EFFORT = ["high"];
22
27
  export function supportsOpenAIModel(model) {
23
- return RESPONSES_REASONING_MODEL.test(model) && !INCOMPATIBLE_MODEL.test(model);
28
+ return ASTRA_MODEL.test(model) ||
29
+ (RESPONSES_REASONING_MODEL.test(model) && !INCOMPATIBLE_MODEL.test(model));
24
30
  }
25
31
  export function openAIEfforts(model) {
26
32
  if (!supportsOpenAIModel(model))
27
33
  return [];
34
+ if (ASTRA_MODEL.test(model))
35
+ return EFFORTS;
28
36
  if (/^gpt-5-pro(?:-|$)/.test(model))
29
37
  return HIGH_ONLY_EFFORT;
30
38
  if (/^gpt-5\.[2-5]-pro(?:-|$)/.test(model))
@@ -39,6 +47,8 @@ export function openAIEfforts(model) {
39
47
  }
40
48
  /** Conservative capacities for the reasoning families accepted by this transport. */
41
49
  export function openAIContextWindow(model) {
50
+ if (ASTRA_MODEL.test(model))
51
+ return usableContext(1_050_000);
42
52
  if (/^gpt-5\.6(?:[.-]|$)/.test(model))
43
53
  return usableContext(1_050_000);
44
54
  if (/^gpt-5(?:[.-]|$)/.test(model))
@@ -52,7 +62,7 @@ function usableContext(tokens) {
52
62
  return Object.freeze({ tokens: Math.floor(tokens * 95 / 100) });
53
63
  }
54
64
  export const openai = {
55
- id: "openai",
65
+ id: ID,
56
66
  defaultModel: "gpt-5",
57
67
  auth: { kind: "api-key", keyVar: KEY },
58
68
  blocked() {
@@ -61,10 +71,15 @@ export const openai = {
61
71
  // The endpoint answers in no order worth keeping, so descending puts the
62
72
  // highest-numbered family — usually the newest — at the top of the menu.
63
73
  async models(signal, onStatus) {
64
- const ids = await listModels(MODELS, headers(requireKey()), signal, onStatus);
65
- return ids
66
- .filter(supportsOpenAIModel)
67
- .sort((a, b) => b.localeCompare(a));
74
+ try {
75
+ const ids = await listModels(MODELS, headers(requireKey()), signal, onStatus, (error) => isRetryableReadFailure(ID, error));
76
+ return ids
77
+ .filter(supportsOpenAIModel)
78
+ .sort((a, b) => b.localeCompare(a));
79
+ }
80
+ catch (error) {
81
+ throwProviderError(ID, signal, error);
82
+ }
68
83
  },
69
84
  async efforts(model) {
70
85
  return openAIEfforts(model);
@@ -76,22 +91,30 @@ export const openai = {
76
91
  async send(req) {
77
92
  const key = requireKey();
78
93
  const effort = requireSupportedEffort(req.model, req.effort, openAIEfforts(req.model));
79
- const events = await postSse(ENDPOINT, headers(key), {
80
- model: req.model,
81
- instructions: req.system,
82
- input: req.messages.flatMap((message) => toWireItems(message)),
83
- tools: req.tools.map(toWireTool),
84
- max_output_tokens: req.maxTokens,
85
- reasoning: { effort, summary: "auto" },
86
- store: false,
87
- include: ["reasoning.encrypted_content"],
88
- stream: true,
89
- }, req.maxTokens, req.signal, req.onStatus);
90
- const data = await assembleOpenAI(events, req.onStream);
91
- const notice = stopNotice(data);
92
- if (notice !== undefined)
93
- req.onStream?.({ kind: "text", text: `\n${notice}` });
94
- return fromWireResponse(data);
94
+ try {
95
+ const events = await postSse(ENDPOINT, headers(key), {
96
+ model: req.model,
97
+ instructions: req.system,
98
+ input: req.messages.flatMap((message) => toWireItems(message)),
99
+ tools: req.tools.map(toWireTool),
100
+ max_output_tokens: req.maxTokens,
101
+ reasoning: { effort, summary: "auto" },
102
+ store: false,
103
+ include: ["reasoning.encrypted_content"],
104
+ stream: true,
105
+ ...(req.identity?.purpose === "turn"
106
+ ? { prompt_cache_key: req.identity.cacheKey }
107
+ : {}),
108
+ }, req.maxTokens, req.signal, req.onStatus, openAIStreamProgress, (error) => isRetryableGenerationFailure(ID, error));
109
+ const data = await assembleOpenAI(events, req.onStream, req.onStatus);
110
+ const notice = stopNotice(data);
111
+ if (notice !== undefined)
112
+ req.onStream?.({ kind: "text", text: `\n${notice}` });
113
+ return fromWireResponse(data);
114
+ }
115
+ catch (error) {
116
+ throwProviderError(ID, req.signal, error);
117
+ }
95
118
  },
96
119
  };
97
120
  function apiKey() {
@@ -104,5 +127,9 @@ function requireKey() {
104
127
  return key;
105
128
  }
106
129
  function headers(key) {
107
- return { authorization: `Bearer ${key}` };
130
+ return {
131
+ authorization: `Bearer ${key}`,
132
+ "user-agent": `jecode/${applicationVersion()} (${process.platform}; ${process.arch})`,
133
+ "x-client-request-id": randomUUID(),
134
+ };
108
135
  }
@@ -4,39 +4,111 @@
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
+ const progressDeadline = idle?.progress === undefined
14
+ ? undefined
15
+ : eventDeadline(idle.progress);
13
16
  try {
14
- for (;;) {
15
- const { done, value } = await reader.read();
16
- if (done)
17
- break;
18
- const text = decoder.decode(value, { stream: true });
19
- total = addBounded(total, text.length, maximumChars, "SSE stream");
20
- for (const payload of parser.push(text))
17
+ let ended = false;
18
+ while (!ended) {
19
+ // One deadline spans every raw read until a complete JSON event lands.
20
+ // SSE comments and partial framing prove only that the socket is alive;
21
+ // they must not keep a model request pending forever.
22
+ const deadline = eventDeadline(idle);
23
+ const payloads = [];
24
+ try {
25
+ while (payloads.length === 0 && !ended) {
26
+ const read = reader.read();
27
+ const pending = progressDeadline === undefined
28
+ ? read
29
+ : progressDeadline.wait(read);
30
+ const { done, value } = await deadline.wait(pending);
31
+ if (done) {
32
+ ended = true;
33
+ const text = decoder.decode();
34
+ total = addBounded(total, text.length, maximumChars, "SSE stream");
35
+ payloads.push(...parser.push(text));
36
+ const payload = parser.finish();
37
+ if (payload !== undefined)
38
+ payloads.push(payload);
39
+ continue;
40
+ }
41
+ const text = decoder.decode(value, { stream: true });
42
+ total = addBounded(total, text.length, maximumChars, "SSE stream");
43
+ payloads.push(...parser.push(text));
44
+ }
45
+ }
46
+ finally {
47
+ deadline.clear();
48
+ }
49
+ for (const payload of payloads) {
50
+ if (idle?.progress?.observed(payload) === true)
51
+ progressDeadline?.reset();
21
52
  yield payload;
53
+ }
22
54
  }
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
55
  finished = true;
33
56
  }
34
57
  finally {
58
+ progressDeadline?.clear();
35
59
  if (!finished)
36
60
  await reader.cancel().catch(() => undefined);
37
61
  reader.releaseLock();
38
62
  }
39
63
  }
64
+ function eventDeadline(idle) {
65
+ if (idle === undefined) {
66
+ return {
67
+ wait: (pending) => pending,
68
+ reset: () => undefined,
69
+ clear: () => undefined,
70
+ };
71
+ }
72
+ let timer;
73
+ let expired;
74
+ let rejectWait;
75
+ const arm = () => {
76
+ if (timer !== undefined)
77
+ clearTimeout(timer);
78
+ expired = undefined;
79
+ timer = setTimeout(() => {
80
+ expired = idle.error();
81
+ const reject = rejectWait;
82
+ rejectWait = undefined;
83
+ reject?.(expired);
84
+ }, idle.milliseconds);
85
+ };
86
+ arm();
87
+ return {
88
+ wait: (pending) => {
89
+ if (expired !== undefined)
90
+ return Promise.reject(expired);
91
+ return new Promise((resolve, reject) => {
92
+ rejectWait = reject;
93
+ pending.then((value) => {
94
+ if (rejectWait === reject)
95
+ rejectWait = undefined;
96
+ resolve(value);
97
+ }, (error) => {
98
+ if (rejectWait === reject)
99
+ rejectWait = undefined;
100
+ reject(error);
101
+ });
102
+ });
103
+ },
104
+ reset: arm,
105
+ clear: () => {
106
+ if (timer !== undefined)
107
+ clearTimeout(timer);
108
+ rejectWait = undefined;
109
+ },
110
+ };
111
+ }
40
112
  // Keep fragments in bounded groups. A provider may split one SSE line into
41
113
  // hundreds of thousands of tiny chunks; repeatedly flattening the growing line
42
114
  // would make parsing quadratic even if boundary scanning itself were linear.
@@ -0,0 +1,32 @@
1
+ // Stable provider-facing conversation identity. It is routing metadata only,
2
+ // never authorization, persistence authority, or a user-visible identifier.
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ const ephemeralSeeds = new WeakMap();
5
+ export function requestIdentityForSession(session) {
6
+ let conversation = session.persistence?.conversationId;
7
+ if (conversation === undefined) {
8
+ conversation = ephemeralSeeds.get(session);
9
+ if (conversation === undefined) {
10
+ conversation = randomUUID();
11
+ ephemeralSeeds.set(session, conversation);
12
+ }
13
+ }
14
+ return identityFromSeed(`${session.provider.id}\0${conversation}`);
15
+ }
16
+ export function resetRequestIdentity(session) {
17
+ ephemeralSeeds.delete(session);
18
+ }
19
+ function identityFromSeed(seed) {
20
+ const digest = createHash("sha256").update(seed).digest("hex");
21
+ const conversationId = [
22
+ digest.slice(0, 8),
23
+ digest.slice(8, 12),
24
+ `5${digest.slice(13, 16)}`,
25
+ `${variant(digest[16])}${digest.slice(17, 20)}`,
26
+ digest.slice(20, 32),
27
+ ].join("-");
28
+ return Object.freeze({ conversationId, cacheKey: `jecode-${digest.slice(0, 32)}` });
29
+ }
30
+ function variant(value) {
31
+ return ((Number.parseInt(value, 16) & 0x3) | 0x8).toString(16);
32
+ }