@giovannijecha/jecode 0.8.5 → 0.8.7

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 (104) hide show
  1. package/README.md +36 -9
  2. package/assets/tokenizers/LICENSE +21 -0
  3. package/assets/tokenizers/o200k-base.tiktoken.gz +0 -0
  4. package/dist/cli-info.js +8 -14
  5. package/dist/commands.js +7 -3
  6. package/dist/config.js +47 -53
  7. package/dist/context/automatic.js +16 -1
  8. package/dist/context/budget.js +5 -2
  9. package/dist/context/compactor.js +91 -37
  10. package/dist/context/diagnostics.js +108 -0
  11. package/dist/context/lifetime.js +18 -0
  12. package/dist/context/manager.js +67 -0
  13. package/dist/context/manual.js +11 -10
  14. package/dist/context/measurement.js +75 -0
  15. package/dist/context/policy.js +13 -10
  16. package/dist/context/request-observation.js +46 -0
  17. package/dist/context/request-projection.js +8 -34
  18. package/dist/context/request.js +22 -0
  19. package/dist/context/tokenizer/bpe.js +68 -0
  20. package/dist/context/tokenizer/o200k.js +54 -0
  21. package/dist/context/tokenizer/vocabulary.js +34 -0
  22. package/dist/controller-request.js +47 -45
  23. package/dist/controller.js +13 -2
  24. package/dist/credential-commands.js +3 -3
  25. package/dist/input-boundary.js +0 -62
  26. package/dist/launch.js +13 -9
  27. package/dist/model-command.js +7 -17
  28. package/dist/oauth-result-page.js +68 -0
  29. package/dist/openai-account-command.js +14 -12
  30. package/dist/openai-account.js +7 -5
  31. package/dist/openai-oauth-callback.js +15 -54
  32. package/dist/openai-oauth-tokens.js +9 -7
  33. package/dist/openai-oauth.js +8 -6
  34. package/dist/permission-command.js +9 -24
  35. package/dist/permissions.js +10 -8
  36. package/dist/provider-commands.js +1 -33
  37. package/dist/provider-errors.js +1 -10
  38. package/dist/provider-label.js +3 -10
  39. package/dist/providers/anthropic-wire.js +1 -1
  40. package/dist/providers/anthropic.js +3 -2
  41. package/dist/providers/index.js +1 -5
  42. package/dist/providers/input-measurement.js +85 -0
  43. package/dist/providers/ollama-context.js +42 -0
  44. package/dist/providers/ollama-endpoint.js +7 -34
  45. package/dist/providers/ollama-wire.js +1 -1
  46. package/dist/providers/ollama.js +15 -147
  47. package/dist/providers/openai-codex.js +7 -4
  48. package/dist/providers/openai-stream.js +20 -8
  49. package/dist/providers/openai-summary.js +37 -0
  50. package/dist/providers/openai-wire.js +1 -1
  51. package/dist/providers/openai.js +3 -1
  52. package/dist/sessions/bucket.js +55 -0
  53. package/dist/sessions/catalog-io.js +162 -0
  54. package/dist/sessions/catalog.js +3 -1
  55. package/dist/sessions/codec-messages.js +122 -0
  56. package/dist/sessions/codec-transcript.js +93 -0
  57. package/dist/sessions/codec-values.js +52 -0
  58. package/dist/sessions/codec.js +4 -257
  59. package/dist/sessions/files.js +158 -0
  60. package/dist/sessions/load.js +90 -0
  61. package/dist/sessions/snapshot.js +33 -0
  62. package/dist/sessions/store.js +42 -461
  63. package/dist/settings.js +17 -16
  64. package/dist/start.js +36 -53
  65. package/dist/timeline.js +2 -0
  66. package/dist/tools/file-read.js +192 -0
  67. package/dist/tools/file-summary.js +9 -0
  68. package/dist/tools/{fs.js → file-write.js} +5 -193
  69. package/dist/tools/glob.js +107 -0
  70. package/dist/tools/index.js +2 -1
  71. package/dist/tools/search.js +1 -105
  72. package/dist/tui/activity.js +1 -1
  73. package/dist/tui/app-input.js +34 -17
  74. package/dist/tui/app-workflows.js +13 -361
  75. package/dist/tui/app.js +55 -28
  76. package/dist/tui/approve.js +3 -2
  77. package/dist/tui/blocks.js +1 -2
  78. package/dist/tui/command-workflow.js +112 -0
  79. package/dist/tui/components/command-menu.js +7 -10
  80. package/dist/tui/components/menu.js +82 -43
  81. package/dist/tui/components/messages.js +16 -9
  82. package/dist/tui/components/status.js +1 -1
  83. package/dist/tui/components/tool-evidence.js +107 -0
  84. package/dist/tui/components/tool-motion.js +32 -0
  85. package/dist/tui/components/tool.js +48 -202
  86. package/dist/tui/frame.js +8 -3
  87. package/dist/tui/help.js +3 -2
  88. package/dist/tui/picker-layout.js +44 -0
  89. package/dist/tui/picker.js +7 -71
  90. package/dist/tui/screen.js +7 -0
  91. package/dist/tui/session-view.js +0 -1
  92. package/dist/tui/tool-details.js +135 -0
  93. package/dist/tui/transcript-grammar.js +1 -1
  94. package/dist/tui/transcript-view.js +30 -112
  95. package/dist/tui/turn-workflow.js +235 -0
  96. package/dist/tui/turn.js +7 -140
  97. package/dist/tui/view.js +4 -4
  98. package/dist/tui/workflow-types.js +2 -0
  99. package/dist/ui/render.js +0 -4
  100. package/package.json +16 -12
  101. package/dist/batch-view.js +0 -17
  102. package/dist/batch.js +0 -267
  103. package/dist/ollama-settings-command.js +0 -74
  104. package/dist/tui/motion.js +0 -32
@@ -0,0 +1,54 @@
1
+ // Owned o200k_base ordinary-text counter. Special-looking user text stays literal.
2
+ import { createHash } from "node:crypto";
3
+ import { countPiece } from "./bpe.js";
4
+ import { vocabulary } from "./vocabulary.js";
5
+ // OpenAI tiktoken's o200k_base split, with scoped ASCII case-insensitivity
6
+ // expanded for JavaScript. Vocabulary and expression attribution: assets/tokenizers/LICENSE.
7
+ const pattern = /[^\r\n\p{L}\p{N}]?[\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{M}]*[\p{Ll}\p{Lm}\p{Lo}\p{M}]+(?:'[sStTmMdD]|'[rR][eE]|'[vV][eE]|'[lL][lL])?|[^\r\n\p{L}\p{N}]?[\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{M}]+[\p{Ll}\p{Lm}\p{Lo}\p{M}]*(?:'[sStTmMdD]|'[rR][eE]|'[vV][eE]|'[lL][lL])?|\p{N}{1,3}| ?[^\p{White_Space}\p{L}\p{N}]+[\r\n/]*|\p{White_Space}*[\r\n]+|\p{White_Space}+(?!\P{White_Space})|\p{White_Space}+/gu;
8
+ const cache = new Map();
9
+ const CHUNK = 8_192;
10
+ export async function countO200k(text, signal) {
11
+ signal?.throwIfAborted();
12
+ const key = createHash("sha256").update(text).digest("hex");
13
+ const cached = cache.get(key);
14
+ if (cached !== undefined)
15
+ return cached;
16
+ const ranks = await vocabulary();
17
+ signal?.throwIfAborted();
18
+ let count = 0;
19
+ // Repetitive logs or minified data must not repeat the same merge work.
20
+ // Keep text only within this call, bounded independently of input size.
21
+ const chunks = new Map();
22
+ let chunkUnits = 0;
23
+ for (let start = 0; start < text.length;) {
24
+ let end = Math.min(start + CHUNK, text.length);
25
+ if (end < text.length && /[\uD800-\uDBFF]/u.test(text[end - 1]))
26
+ end--;
27
+ const chunk = text.slice(start, end);
28
+ let tokens = chunks.get(chunk);
29
+ if (tokens === undefined) {
30
+ tokens = 0;
31
+ for (const match of chunk.matchAll(pattern))
32
+ tokens += countPiece(Buffer.from(match[0], "utf8"), ranks);
33
+ while (chunkUnits + chunk.length > 131_072) {
34
+ const oldest = chunks.keys().next().value;
35
+ chunks.delete(oldest);
36
+ chunkUnits -= oldest.length;
37
+ }
38
+ chunks.set(chunk, tokens);
39
+ chunkUnits += chunk.length;
40
+ }
41
+ count += tokens;
42
+ // Bounded slices may split a token/pre-token. Reserve boundary room; this
43
+ // is an input estimate, not a claim of exact whole-request tokenization.
44
+ if (end < text.length)
45
+ count += 8;
46
+ start = end;
47
+ await new Promise((resolve) => setImmediate(resolve));
48
+ signal?.throwIfAborted();
49
+ }
50
+ if (cache.size >= 2_048)
51
+ cache.delete(cache.keys().next().value);
52
+ cache.set(key, count);
53
+ return count;
54
+ }
@@ -0,0 +1,34 @@
1
+ // Pinned data, loaded once on demand. No network, executable data, or user cache.
2
+ import { createHash } from "node:crypto";
3
+ import { readFile } from "node:fs/promises";
4
+ import { promisify } from "node:util";
5
+ import { gunzip } from "node:zlib";
6
+ let loading;
7
+ const unpack = promisify(gunzip);
8
+ const digest = "446a9538cb6c348e3516120d7c08b09f57c36495e2acfffe59a5bf8b0cfb1a2d";
9
+ export function vocabulary() {
10
+ loading ??= load();
11
+ return loading;
12
+ }
13
+ async function load() {
14
+ const compressed = await readFile(new URL("../../../assets/tokenizers/o200k-base.tiktoken.gz", import.meta.url));
15
+ if (compressed.length > 2_000_000)
16
+ throw new Error("token vocabulary exceeds its size limit");
17
+ const data = await unpack(compressed, { maxOutputLength: 4_000_000 });
18
+ if (createHash("sha256").update(data).digest("hex") !== digest) {
19
+ throw new Error("token vocabulary checksum mismatch");
20
+ }
21
+ const ranks = new Map();
22
+ const lines = data.toString("ascii").trimEnd().split("\n");
23
+ for (let index = 0; index < lines.length; index++) {
24
+ const [encoded, rank] = lines[index].split(" ");
25
+ if (encoded === undefined || Number(rank) !== index)
26
+ throw new Error("invalid token vocabulary");
27
+ ranks.set(Buffer.from(encoded, "base64").toString("latin1"), index);
28
+ if (index % 2_048 === 0)
29
+ await new Promise((resolve) => setImmediate(resolve));
30
+ }
31
+ if (ranks.size !== 199_998)
32
+ throw new Error("incomplete token vocabulary");
33
+ return ranks;
34
+ }
@@ -1,18 +1,22 @@
1
1
  // One streamed provider request with a single safe context-overflow recovery.
2
- import { budgetRequestFromInputTokens, estimateRequestInputTokensResponsive, } from "./context/budget.js";
2
+ import { budgetRequestFromInputTokens, } from "./context/budget.js";
3
3
  import { isContextOverflow } from "./context/policy.js";
4
- import { projectToolResults, projectToolResultsNewest, toolResultProjectionBudget, } from "./context/request-projection.js";
5
- export async function requestAssistant(history, current, specs, options, events, signal) {
4
+ import { fitRequestInput } from "./context/request.js";
5
+ import { observePreparation, sendObserved } from "./context/request-observation.js";
6
+ export async function requestAssistant(history, current, specs, options, events, meter, signal) {
7
+ let preparing = performance.now();
6
8
  let policy = await options.contextPolicy();
7
- const prepared = await prepareContext(history, current, specs, options, events, policy, "budget", signal);
9
+ const prepared = await prepareContext(history, current, specs, options, events, meter, policy, "budget", signal);
8
10
  let context = prepared.context;
9
11
  let requestMessages = prepared.requestMessages;
10
12
  let inputTokens = prepared.inputTokens;
13
+ let measurement = prepared.measurement;
14
+ let clippedResults = prepared.clippedResults;
11
15
  let recovered = false;
12
16
  for (;;) {
13
17
  const budget = budgetRequestFromInputTokens(inputTokens, options.maxTokens, policy);
14
18
  try {
15
- const message = await options.provider.send({
19
+ const message = await sendObserved(options.provider, {
16
20
  model: options.model,
17
21
  system: options.system,
18
22
  messages: requestMessages,
@@ -25,59 +29,57 @@ export async function requestAssistant(history, current, specs, options, events,
25
29
  signal,
26
30
  onStream: (event) => events.onStream(event),
27
31
  onStatus: (status) => events.onStatus?.(status),
28
- });
29
- return { message, context, inputTokens: budget.inputTokens };
32
+ }, measurement, policy, performance.now() - preparing, clippedResults);
33
+ return { message, context, inputTokens: budget.inputTokens, measurement };
30
34
  }
31
35
  catch (error) {
32
- if (recovered)
36
+ if (recovered || !isContextOverflow(error))
33
37
  throw error;
34
- if (isContextOverflow(error))
35
- policy = await options.contextPolicy();
36
- const next = await prepareContext(history, context, specs, options, events, policy, "overflow", signal, error);
38
+ preparing = performance.now();
39
+ policy = await options.contextPolicy();
40
+ const next = await prepareContext(history, context, specs, options, events, meter, policy, "overflow", signal, error);
37
41
  if (sameContext(next.context, context))
38
42
  throw error;
39
43
  context = next.context;
40
44
  requestMessages = next.requestMessages;
41
45
  inputTokens = next.inputTokens;
46
+ measurement = next.measurement;
47
+ clippedResults = next.clippedResults;
42
48
  recovered = true;
43
49
  }
44
50
  }
45
51
  }
46
- async function prepareContext(history, context, specs, options, events, policy, reason, signal, error) {
47
- const projectionBudget = toolResultProjectionBudget(policy);
48
- const initialProjection = projectToolResults(context, projectionBudget);
49
- const initialRequest = initialProjection.messages;
50
- const inputTokens = await estimateRequestInputTokensResponsive({
51
- system: options.system,
52
- messages: initialRequest,
53
- tools: specs,
54
- }, signal);
55
- const projected = await events.onContext?.(history, context, {
56
- reason,
57
- policy,
58
- inputTokens,
59
- projectionSaturated: initialProjection.saturated,
60
- ...(error === undefined ? {} : { error }),
52
+ async function prepareContext(history, context, specs, options, events, meter, policy, reason, signal, error) {
53
+ return observePreparation(policy, reason, signal, async () => {
54
+ const input = {
55
+ model: options.model,
56
+ effort: options.effort,
57
+ system: options.system,
58
+ messages: [...context],
59
+ tools: specs,
60
+ };
61
+ const initial = await meter.measure(input, signal);
62
+ const projected = await events.onContext?.(history, context, {
63
+ reason,
64
+ policy,
65
+ inputTokens: initial.inputTokens,
66
+ ...(error === undefined ? {} : { error }),
67
+ });
68
+ const semantic = projected === undefined ? clone(context) : clone(projected);
69
+ if (projected !== undefined)
70
+ meter.reset();
71
+ const semanticInput = { ...input, messages: semantic };
72
+ const measurement = projected === undefined ? initial : await meter.measure(semanticInput, signal);
73
+ const fitted = await fitRequestInput(semanticInput, meter, policy, measurement, signal);
74
+ budgetRequestFromInputTokens(fitted.measurement.inputTokens, options.maxTokens, policy);
75
+ return {
76
+ context: semantic,
77
+ requestMessages: fitted.messages,
78
+ inputTokens: fitted.measurement.inputTokens,
79
+ measurement: fitted.measurement,
80
+ clippedResults: fitted.clippedResults,
81
+ };
61
82
  });
62
- const semantic = projected === undefined ? clone(context) : clone(projected);
63
- const stableProjection = projected === undefined
64
- ? initialProjection
65
- : projectToolResults(semantic, projectionBudget);
66
- const requestMessages = stableProjection.saturated
67
- ? projectToolResultsNewest(semantic, projectionBudget).messages
68
- : stableProjection.messages;
69
- const canReuseEstimate = projected === undefined && !stableProjection.saturated;
70
- return {
71
- context: semantic,
72
- requestMessages,
73
- inputTokens: canReuseEstimate
74
- ? inputTokens
75
- : await estimateRequestInputTokensResponsive({
76
- system: options.system,
77
- messages: requestMessages,
78
- tools: specs,
79
- }, signal),
80
- };
81
83
  }
82
84
  function clone(messages) {
83
85
  return structuredClone([...messages]);
@@ -6,6 +6,7 @@
6
6
  import { isToolCall } from "./types.js";
7
7
  import { findTool, runTool, toolSpecs } from "./tools/index.js";
8
8
  import { requestAssistant } from "./controller-request.js";
9
+ import { inputMeter } from "./context/measurement.js";
9
10
  export const MAX_TOOL_CALLS_PER_RESPONSE = 32;
10
11
  /** Independent read calls share one bounded execution wave. */
11
12
  export const MAX_CONCURRENT_TOOL_CALLS = 4;
@@ -16,6 +17,7 @@ export const MAX_CONCURRENT_TOOL_CALLS = 4;
16
17
  */
17
18
  export async function runTurn(history, options, events, signal, modelHistory = history) {
18
19
  const specs = toolSpecs(options.tools);
20
+ const meter = options.inputMeter ?? inputMeter(options.provider);
19
21
  let context = modelHistory;
20
22
  throwIfAborted(signal);
21
23
  const append = (message) => {
@@ -43,13 +45,13 @@ export async function runTurn(history, options, events, signal, modelHistory = h
43
45
  if (options.maxModelRequests !== undefined &&
44
46
  requests >= options.maxModelRequests) {
45
47
  const requestLabel = options.maxModelRequests === 1 ? "request" : "requests";
46
- throw new Error(`stopped after ${options.maxModelRequests} model ${requestLabel} (--max-steps limit reached)`);
48
+ throw new Error(`stopped after ${options.maxModelRequests} model ${requestLabel} (request budget reached)`);
47
49
  }
48
50
  appendSteering(options.steering?.drain() ?? []);
49
51
  requests++;
50
52
  // The message is displayed as it streams; what comes back here is the
51
53
  // assembled version, which exists to be appended to the history.
52
- const response = await requestAssistant(history, context, specs, options, events, signal);
54
+ const response = await requestAssistant(history, context, specs, options, events, meter, signal);
53
55
  const assistant = response.message;
54
56
  context = response.context;
55
57
  throwIfAborted(signal);
@@ -63,6 +65,7 @@ export async function runTurn(history, options, events, signal, modelHistory = h
63
65
  assertToolCallIds(calls);
64
66
  if (assistant.usage !== undefined)
65
67
  events.onUsage?.(assistant.usage);
68
+ meter.observe(response.measurement, assistant.usage?.inputTokens);
66
69
  events.onRequestInput?.(assistant.usage !== undefined && assistant.usage.inputTokens > 0
67
70
  ? assistant.usage.inputTokens
68
71
  : response.inputTokens);
@@ -193,11 +196,17 @@ async function settle(call, current, total, options, events, signal, preview) {
193
196
  if (tool === undefined) {
194
197
  return refuse(call, `no such tool: ${call.name}`, "unknown tool");
195
198
  }
199
+ if (options.toolAllowed?.(call) === false) {
200
+ return refuse(call, "this tool is denied by the current session permissions", "denied");
201
+ }
196
202
  const approved = !tool.dangerous || await events.approve(call);
197
203
  throwIfAborted(signal);
198
204
  if (!approved) {
199
205
  return refuse(call, "the user declined this call — ask them how to proceed", "declined");
200
206
  }
207
+ if (options.toolAllowed?.(call) === false) {
208
+ return refuse(call, "this tool is denied by the current session permissions", "denied");
209
+ }
201
210
  throwIfAborted(signal);
202
211
  events.onToolStart?.(call, current, total);
203
212
  return runTool(tool, call, {
@@ -223,6 +232,8 @@ function refuse(call, reason, summary) {
223
232
  async function look(call, options, signal) {
224
233
  if (call.inputError !== undefined)
225
234
  return undefined;
235
+ if (options.toolAllowed?.(call) === false)
236
+ return undefined;
226
237
  const tool = findTool(options.tools, call.name);
227
238
  if (tool?.preview === undefined)
228
239
  return undefined;
@@ -64,13 +64,13 @@ export async function apiKeyCommand(name, label, session, host, providerId) {
64
64
  if (!hasSaved(name)) {
65
65
  host.emit({
66
66
  kind: "notice",
67
- text: `${label} API key comes from the environment · restart after changing it`,
67
+ text: `${label} key comes from the environment · restart after changing it`,
68
68
  tone: "info",
69
69
  });
70
70
  return;
71
71
  }
72
72
  const action = await choose({
73
- title: heading(`${label} API key`, `${name} · environment`, session.palette),
73
+ title: heading(`${label} key`, `${name} · environment`, session.palette),
74
74
  description: "The environment value is read-only. A saved copy is currently shadowed.",
75
75
  options: [{ label: "forget saved copy", hint: storeLabel(), key: "f" }],
76
76
  index: 0,
@@ -93,7 +93,7 @@ export async function apiKeyCommand(name, label, session, host, providerId) {
93
93
  : []),
94
94
  ];
95
95
  const index = await choose({
96
- title: heading(`${label} API key`, `${name} · ${source ?? "missing"}`, session.palette),
96
+ title: heading(`${label} key`, `${name} · ${source ?? "missing"}`, session.palette),
97
97
  options: actions,
98
98
  index: 0,
99
99
  });
@@ -1,6 +1,4 @@
1
1
  // One bounded ingress for text that can become a user prompt.
2
- import { Buffer } from "node:buffer";
3
- import { StringDecoder } from "node:string_decoder";
4
2
  import { MAX_TEXT_CODE_UNITS } from "./text-boundary.js";
5
3
  export const MAX_PROMPT_CODE_UNITS = MAX_TEXT_CODE_UNITS;
6
4
  export const PROMPT_LIMIT_MESSAGE = `Prompt cannot exceed ${MAX_PROMPT_CODE_UNITS.toLocaleString("en-US")} UTF-16 code units`;
@@ -18,63 +16,3 @@ export function assertPromptAppend(current, added) {
18
16
  if (added > MAX_PROMPT_CODE_UNITS - current)
19
17
  throw new PromptLimitError();
20
18
  }
21
- /**
22
- * Split raw UTF-8 input without letting one unterminated line grow past the
23
- * prompt boundary. Newline, CRLF, and a final line without a newline match the
24
- * line semantics used by batch mode.
25
- */
26
- export async function* boundedInputLines(source) {
27
- const decoder = new StringDecoder("utf8");
28
- let line = "";
29
- let pendingCr = false;
30
- const append = (text, from, to) => {
31
- const length = to - from;
32
- assertPromptAppend(line.length, length);
33
- if (length > 0)
34
- line += text.slice(from, to);
35
- };
36
- const consume = function* (text) {
37
- if (text === "")
38
- return;
39
- let from = 0;
40
- if (pendingCr) {
41
- if (text.startsWith("\n"))
42
- from = 1;
43
- pendingCr = false;
44
- yield line;
45
- line = "";
46
- }
47
- while (from < text.length) {
48
- const cr = text.indexOf("\r", from);
49
- const lf = text.indexOf("\n", from);
50
- const newline = cr === -1 ? lf : lf === -1 ? cr : Math.min(cr, lf);
51
- if (newline === -1) {
52
- append(text, from, text.length);
53
- return;
54
- }
55
- append(text, from, newline);
56
- if (text[newline] === "\r" && newline + 1 === text.length) {
57
- pendingCr = true;
58
- return;
59
- }
60
- yield line;
61
- line = "";
62
- from = text[newline] === "\r" && text[newline + 1] === "\n"
63
- ? newline + 2
64
- : newline + 1;
65
- }
66
- };
67
- for await (const chunk of source) {
68
- const text = typeof chunk === "string" ? chunk : decoder.write(Buffer.from(chunk));
69
- yield* consume(text);
70
- }
71
- const tail = decoder.end();
72
- if (tail !== "")
73
- yield* consume(tail);
74
- if (pendingCr) {
75
- yield line;
76
- line = "";
77
- }
78
- if (line !== "")
79
- yield line;
80
- }
package/dist/launch.js CHANGED
@@ -1,19 +1,23 @@
1
1
  export function parseLaunch(argv) {
2
+ if (argv.includes("--latest")) {
3
+ throw new Error("--latest has been renamed to --last; use `jecode -c` or `jecode resume --last`");
4
+ }
2
5
  const first = argv[0];
3
- if (first !== undefined && !first.startsWith("--") && first !== "-h" && first !== "-v") {
4
- if (first !== "resume")
5
- throw new Error(`unknown command ${first}`);
6
- const rest = argv.slice(1);
7
- const latest = rest.filter((value) => value === "--latest").length;
6
+ if (first === "resume" || first === "-c") {
7
+ const rest = first === "-c" ? ["--last", ...argv.slice(1)] : argv.slice(1);
8
+ const latest = rest.filter((value) => value === "--last").length;
8
9
  if (latest > 1)
9
- throw new Error("--latest may be passed only once");
10
+ throw new Error("--last may be passed only once; -c already selects the last session");
10
11
  return {
11
12
  kind: "resume",
12
13
  latest: latest === 1,
13
- configArgs: rest.filter((value) => value !== "--latest"),
14
+ configArgs: rest.filter((value) => value !== "--last"),
14
15
  };
15
16
  }
16
- if (argv.includes("--latest"))
17
- throw new Error("--latest requires `jecode resume`");
17
+ if (first !== undefined && !first.startsWith("--") && first !== "-h" && first !== "-v") {
18
+ throw new Error(`unknown command ${first}`);
19
+ }
20
+ if (argv.includes("--last"))
21
+ throw new Error("--last requires `jecode resume`; use `jecode -c` to continue directly");
18
22
  return { kind: "new", latest: false, configArgs: [...argv] };
19
23
  }
@@ -17,9 +17,6 @@ export async function modelsCommand(session, host, behavior = {}, providers = PR
17
17
  const connected = availability
18
18
  .filter((entry) => entry.blocked === undefined)
19
19
  .map((entry) => entry.provider);
20
- const disconnected = availability
21
- .filter((entry) => entry.blocked !== undefined)
22
- .map((entry) => entry.provider);
23
20
  if (connected.length === 0) {
24
21
  host.emit({
25
22
  kind: "notice",
@@ -68,10 +65,15 @@ export async function modelsCommand(session, host, behavior = {}, providers = PR
68
65
  });
69
66
  return false;
70
67
  }
71
- const description = catalogDescription(disconnected, failed);
68
+ if (failed.length > 0) {
69
+ host.emit({
70
+ kind: "notice",
71
+ text: `model catalogs unavailable: ${failed.map((entry) => providerLabel(entry.provider.id)).join(", ")}`,
72
+ tone: "warn",
73
+ });
74
+ }
72
75
  const index = await choose({
73
76
  title: [],
74
- ...(description === undefined ? {} : { description }),
75
77
  options: choices.map((choice) => ({
76
78
  label: choice.model,
77
79
  // Provider identity is part of the choice, not optional help: keep it
@@ -136,18 +138,6 @@ async function alignEffort(provider, model, effort, host) {
136
138
  return { ok: false };
137
139
  }
138
140
  }
139
- function catalogDescription(disconnected, failed) {
140
- const parts = [
141
- "The connection on the right will run this model; API and account usage stay separate",
142
- disconnected.length === 0
143
- ? undefined
144
- : `Not connected: ${disconnected.map((provider) => providerLabel(provider.id)).join(", ")}`,
145
- failed.length === 0
146
- ? undefined
147
- : `Unavailable: ${failed.map((entry) => providerLabel(entry.provider.id)).join(", ")}`,
148
- ].filter((part) => part !== undefined);
149
- return `${parts.join(" · ")} · manage access in /providers`;
150
- }
151
141
  function emptyCatalogMessage(connected, failed) {
152
142
  if (failed.length === 1 && connected.length === 1) {
153
143
  const one = failed[0];
@@ -0,0 +1,68 @@
1
+ // The browser handoff back to the terminal after account sign-in.
2
+ import { readFileSync } from "node:fs";
3
+ export function oauthResultPage(success) {
4
+ const title = success ? "You're signed in" : "Sign-in failed";
5
+ const detail = success
6
+ ? "Return to your terminal. You can close this tab."
7
+ : "Return to your terminal to try again.";
8
+ return `<!doctype html>
9
+ <html lang="en">
10
+ <head>
11
+ <meta charset="utf-8">
12
+ <meta name="viewport" content="width=device-width, initial-scale=1">
13
+ <meta name="color-scheme" content="dark">
14
+ <title>${title} · Jecode</title>
15
+ <style>
16
+ :root {
17
+ color-scheme: dark;
18
+ --background: #1c2026;
19
+ --text: #ebeff4;
20
+ --muted: #9ca9b7;
21
+ --accent: #669bd2;
22
+ --error: #e87070;
23
+ }
24
+ * { box-sizing: border-box; }
25
+ body {
26
+ margin: 0;
27
+ min-height: 100vh;
28
+ min-height: 100svh;
29
+ display: grid;
30
+ place-items: center;
31
+ background: var(--background);
32
+ color: var(--text);
33
+ font-family: "Segoe UI", system-ui, sans-serif;
34
+ }
35
+ main { width: min(28rem, 100%); padding: 2rem; }
36
+ .identity { display: flex; align-items: center; gap: .75rem; margin-bottom: 2rem; }
37
+ .identity img { display: block; width: 3rem; height: 3rem; }
38
+ .identity span {
39
+ color: var(--accent);
40
+ font: 600 1.25rem/1 ui-monospace, "Cascadia Mono", Consolas, monospace;
41
+ letter-spacing: -.04em;
42
+ }
43
+ h1 { margin: 0; font-size: 1.75rem; font-weight: 600; line-height: 1.25; letter-spacing: -.025em; }
44
+ p { margin: .75rem 0 0; color: var(--muted); font-size: 1rem; line-height: 1.6; }
45
+ .failure h1 { color: var(--error); }
46
+ </style>
47
+ </head>
48
+ <body>
49
+ <main${success ? "" : ' class="failure"'}>
50
+ <div class="identity">
51
+ <img src="${mascotDataUri()}" width="48" height="48" alt="">
52
+ <span>jecode</span>
53
+ </div>
54
+ <h1>${title}</h1>
55
+ <p>${detail}</p>
56
+ </main>
57
+ <script>history.replaceState(null,"","/auth/complete")</script>
58
+ </body>
59
+ </html>`;
60
+ }
61
+ let mascot;
62
+ function mascotDataUri() {
63
+ if (mascot === undefined) {
64
+ const file = new URL("../assets/jeco-256.png", import.meta.url);
65
+ mascot = `data:image/png;base64,${readFileSync(file).toString("base64")}`;
66
+ }
67
+ return mascot;
68
+ }
@@ -1,9 +1,11 @@
1
- // Interactive ChatGPT account management for the OpenAI Codex provider.
1
+ // Interactive account management for the OpenAI Codex provider.
2
2
  import { openAICodexAccount } from "./accounts.js";
3
3
  import { headlessEnvironment, openExternal } from "./external-browser.js";
4
4
  import { openAIAccountHint, removeOpenAIAccount, saveOpenAIAccount, } from "./openai-account.js";
5
5
  import { beginBrowserLogin, beginDeviceLogin, } from "./openai-oauth.js";
6
+ import { providerLabel } from "./provider-label.js";
6
7
  import { heading } from "./tui/picker.js";
8
+ const ACCOUNT_LABEL = providerLabel("openai-codex");
7
9
  const DEFAULT_DEPENDENCIES = {
8
10
  beginBrowser: beginBrowserLogin,
9
11
  beginDevice: beginDeviceLogin,
@@ -16,12 +18,12 @@ export async function openAIAccountCommand(session, host, dependencies = DEFAULT
16
18
  const connected = openAICodexAccount() !== undefined;
17
19
  const actions = connected
18
20
  ? [
19
- { label: "reconnect ChatGPT", hint: "replace the current sign-in", key: "r" },
21
+ { label: `reconnect ${ACCOUNT_LABEL}`, hint: "replace the current sign-in", key: "r" },
20
22
  { label: "sign out", hint: "remove the saved account", key: "s" },
21
23
  ]
22
- : [{ label: "connect ChatGPT", hint: "sign in with OpenAI", key: "c" }];
24
+ : [{ label: `connect ${ACCOUNT_LABEL}`, hint: "sign in with OpenAI", key: "c" }];
23
25
  const action = await host.choose({
24
- title: heading("ChatGPT", openAIAccountHint(), session.palette),
26
+ title: heading(ACCOUNT_LABEL, openAIAccountHint(), session.palette),
25
27
  options: actions,
26
28
  index: 0,
27
29
  });
@@ -35,8 +37,8 @@ export async function openAIAccountCommand(session, host, dependencies = DEFAULT
35
37
  host.emit({
36
38
  kind: "notice",
37
39
  text: result.revokeFailed
38
- ? `ChatGPT disconnected · remote sign-out could not be confirmed${route}`
39
- : `ChatGPT disconnected${route}`,
40
+ ? `${ACCOUNT_LABEL} disconnected · remote sign-out could not be confirmed${route}`
41
+ : `${ACCOUNT_LABEL} disconnected${route}`,
40
42
  tone: result.revokeFailed ? "warn" : "info",
41
43
  });
42
44
  return result.removed;
@@ -56,14 +58,14 @@ async function connectOpenAIAccount(session, host, dependencies) {
56
58
  { label: "sign in with device code", hint: "WSL, SSH, or headless", key: "d" },
57
59
  ];
58
60
  const choice = await host.choose({
59
- title: heading("connect ChatGPT", "OpenAI OAuth", session.palette),
61
+ title: heading(`connect ${ACCOUNT_LABEL}`, "OpenAI OAuth", session.palette),
60
62
  description: "Choose the flow for this terminal. Your password stays on OpenAI's website.",
61
63
  options: methods,
62
64
  index: dependencies.headless() ? 1 : 0,
63
65
  });
64
66
  if (choice === undefined)
65
67
  return false;
66
- host.status?.("Starting ChatGPT sign-in");
68
+ host.status?.(`Starting ${ACCOUNT_LABEL} sign-in`);
67
69
  let login;
68
70
  try {
69
71
  login = choice === 1
@@ -97,8 +99,8 @@ async function waitForLogin(login, session, host, openUrl) {
97
99
  host.emit({
98
100
  kind: "notice",
99
101
  text: session.provider.id === "openai-codex"
100
- ? "ChatGPT connected · current provider route"
101
- : "ChatGPT connected · choose a ChatGPT model in /models to use this account",
102
+ ? `${ACCOUNT_LABEL} connected · current provider route`
103
+ : `${ACCOUNT_LABEL} connected · choose an ${ACCOUNT_LABEL} model in /models to use this account`,
102
104
  tone: "info",
103
105
  });
104
106
  return true;
@@ -111,7 +113,7 @@ async function waitForLogin(login, session, host, openUrl) {
111
113
  await openUrl(login.url);
112
114
  continue;
113
115
  }
114
- local.abort(new Error("ChatGPT sign-in cancelled"));
116
+ local.abort(new Error(`${ACCOUNT_LABEL} sign-in cancelled`));
115
117
  return false;
116
118
  }
117
119
  }
@@ -124,7 +126,7 @@ function waitingPicker(login, session) {
124
126
  ? "Finish sign-in in your browser. Jecode will continue automatically."
125
127
  : `Enter ${login.code} on OpenAI's device page. Jecode will continue automatically.`;
126
128
  return {
127
- title: heading("ChatGPT sign-in", login.code ?? "waiting for browser", session.palette),
129
+ title: heading(`${ACCOUNT_LABEL} sign-in`, login.code ?? "waiting for browser", session.palette),
128
130
  description: detail,
129
131
  options: [
130
132
  { label: "open browser again", key: "o" },
@@ -1,6 +1,8 @@
1
- // The live ChatGPT account: persistence, proactive refresh, and logout.
1
+ // The live OpenAI account: persistence, proactive refresh, and logout.
2
2
  import { openAICodexAccount, updateOpenAICodexAccount } from "./accounts.js";
3
3
  import { refreshOpenAITokens, revokeOpenAITokens } from "./openai-oauth.js";
4
+ import { providerLabel } from "./provider-label.js";
5
+ const ACCOUNT_LABEL = providerLabel("openai-codex");
4
6
  const REFRESH_EARLY_MS = 5 * 60_000;
5
7
  let refreshing;
6
8
  export function openAIAccountHint() {
@@ -33,7 +35,7 @@ export async function removeOpenAIAccount(signal) {
33
35
  export async function openAIAuthorization(rejectedToken, signal, onStatus) {
34
36
  const account = openAICodexAccount();
35
37
  if (account === undefined)
36
- throw new Error("ChatGPT account is not connected");
38
+ throw new Error(`${ACCOUNT_LABEL} is not connected`);
37
39
  const mustRefresh = rejectedToken !== undefined || expiresSoon(account);
38
40
  const ready = mustRefresh
39
41
  ? await refreshAccount(rejectedToken, signal, onStatus)
@@ -47,14 +49,14 @@ async function refreshAccount(rejectedToken, signal, onStatus) {
47
49
  throw abortReason(signal);
48
50
  const task = updateOpenAICodexAccount(async (current) => {
49
51
  if (current === undefined)
50
- throw new Error("ChatGPT account is not connected");
52
+ throw new Error(`${ACCOUNT_LABEL} is not connected`);
51
53
  if (!requiresAuthorityRefresh(current, rejectedToken))
52
54
  return current;
53
- onStatus?.("Refreshing ChatGPT sign-in");
55
+ onStatus?.(`Refreshing ${ACCOUNT_LABEL} sign-in`);
54
56
  return refreshOpenAITokens(current);
55
57
  }).then((account) => {
56
58
  if (account === undefined)
57
- throw new Error("ChatGPT account is not connected");
59
+ throw new Error(`${ACCOUNT_LABEL} is not connected`);
58
60
  return account;
59
61
  });
60
62
  refreshing = task;