@giovannijecha/jecode 0.3.2 → 0.5.0

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 (54) hide show
  1. package/README.md +63 -23
  2. package/dist/batch.js +22 -2
  3. package/dist/cli-info.js +3 -0
  4. package/dist/command-settings.js +19 -0
  5. package/dist/commands.js +11 -14
  6. package/dist/config.js +2 -0
  7. package/dist/controller.js +3 -0
  8. package/dist/conversation.js +208 -0
  9. package/dist/credential-commands.js +63 -80
  10. package/dist/credentials.js +7 -1
  11. package/dist/launch.js +19 -0
  12. package/dist/model-command.js +171 -0
  13. package/dist/permission-command.js +52 -53
  14. package/dist/provider-commands.js +71 -228
  15. package/dist/provider-errors.js +4 -3
  16. package/dist/provider-label.js +2 -2
  17. package/dist/providers/ollama.js +8 -3
  18. package/dist/sessions/codec.js +344 -0
  19. package/dist/sessions/lease.js +76 -0
  20. package/dist/sessions/runtime.js +73 -0
  21. package/dist/sessions/store.js +368 -0
  22. package/dist/settings-command.js +43 -98
  23. package/dist/start.js +67 -4
  24. package/dist/transcript-types.js +6 -0
  25. package/dist/tui/activity.js +3 -0
  26. package/dist/tui/app-input.js +9 -6
  27. package/dist/tui/app-workflows.js +37 -8
  28. package/dist/tui/app.js +72 -20
  29. package/dist/tui/components/composer.js +1 -1
  30. package/dist/tui/components/footer.js +1 -1
  31. package/dist/tui/components/menu.js +27 -15
  32. package/dist/tui/components/messages.js +1 -15
  33. package/dist/tui/components/prompt.js +2 -2
  34. package/dist/tui/components/status.js +5 -2
  35. package/dist/tui/components/tool.js +22 -5
  36. package/dist/tui/editor.js +54 -7
  37. package/dist/tui/feedback.js +5 -2
  38. package/dist/tui/field.js +1 -1
  39. package/dist/tui/help.js +4 -1
  40. package/dist/tui/input.js +4 -0
  41. package/dist/tui/keys.js +14 -3
  42. package/dist/tui/overlay.js +6 -0
  43. package/dist/tui/picker.js +26 -10
  44. package/dist/tui/resume.js +24 -0
  45. package/dist/tui/session-view.js +2 -2
  46. package/dist/tui/turn.js +2 -3
  47. package/dist/tui/view.js +1 -1
  48. package/dist/ui/diff.js +2 -0
  49. package/dist/ui/inline.js +2 -2
  50. package/dist/ui/markdown.js +7 -7
  51. package/dist/ui/render.js +2 -0
  52. package/dist/ui/theme.js +4 -4
  53. package/dist/usage.js +9 -0
  54. package/package.json +1 -1
@@ -1,227 +1,81 @@
1
- // Provider and model command flows.
2
- import { heading } from "./tui/picker.js";
3
- import { PROVIDERS } from "./providers/index.js";
4
- import { readSettings } from "./settings.js";
5
- import { authenticationNeed, ensureProviderAuthentication, } from "./credential-commands.js";
6
- import { providerFailure } from "./provider-errors.js";
1
+ // Provider access and connection management. Runtime selection lives in /models.
2
+ import { saveCommandSettings } from "./command-settings.js";
3
+ import { apiKeyCommand } from "./credential-commands.js";
4
+ import { credentialSource } from "./credentials.js";
5
+ import { ollamaConnectionHint, ollamaConnectionSetting, } from "./ollama-settings-command.js";
6
+ import { openAIAccountHint } from "./openai-account.js";
7
+ import { openAIAccountCommand } from "./openai-account-command.js";
7
8
  import { providerLabel } from "./provider-label.js";
8
- import { compatibleEffort } from "./effort.js";
9
- /**
10
- * The provider menu.
11
- *
12
- * Every provider is offered, including the ones that cannot run: the reason
13
- * one is unusable — the variable it wants, by name — is worth more on screen
14
- * than the row would be worth hidden. A blocked choice opens the same masked
15
- * credential flow used by settings, and cancellation leaves the old choice.
16
- */
17
- export async function providersCommand(session, host, behavior = {}) {
9
+ import { PROVIDERS } from "./providers/index.js";
10
+ import { ollamaConnection } from "./providers/ollama.js";
11
+ import { heading } from "./tui/picker.js";
12
+ const OLLAMA_KEY = "OLLAMA_API_KEY";
13
+ /** The single control plane for API keys, OAuth accounts, and Ollama endpoints. */
14
+ export async function providersCommand(session, host) {
18
15
  const choose = chooser(host);
19
16
  if (choose === undefined)
20
- return false;
21
- const options = PROVIDERS.map((provider) => ({
22
- label: provider.id,
23
- hint: provider.blocked() ?? "ready",
24
- }));
25
- const at = PROVIDERS.findIndex((provider) => provider.id === session.provider.id);
26
- const index = await choose({
27
- title: heading("provider", "where the next turn runs", session.palette),
28
- options,
29
- index: Math.max(0, at),
30
- });
31
- if (index === undefined)
32
- return false;
33
- const chosen = PROVIDERS[index];
34
- if (chosen === undefined)
35
- return false;
36
- // Picking the provider already in use is not a no-op when it cannot run:
37
- // it is how the user asks to fix the reason it cannot.
38
- if (chosen.id === session.provider.id) {
39
- if (!(await ensureProviderAuthentication(chosen, session, host)))
40
- return false;
41
- return session.model === ""
42
- ? modelsCommand(session, host, { announce: false, save: behavior.save })
43
- : true;
44
- }
45
- // A provider that cannot run is worth one offer to fix it, here, rather
46
- // than a note telling the user to leave and export something.
47
- const blocked = chosen.blocked();
48
- if (blocked !== undefined) {
49
- await ensureProviderAuthentication(chosen, session, host);
50
- const still = chosen.blocked();
51
- if (still !== undefined) {
52
- host.emit({
53
- kind: "notice",
54
- text: `${providerLabel(chosen.id)} still needs ${authenticationNeed(chosen)} · provider unchanged`,
55
- tone: "warn",
56
- });
57
- return false;
58
- }
59
- }
60
- const before = {
61
- provider: session.provider,
62
- model: session.model,
63
- providerId: session.config.providerId,
64
- configModel: session.config.model,
65
- effort: session.config.effort,
66
- };
67
- session.provider = chosen;
68
- // The model belonged to the old provider. Carrying it across would send
69
- // `claude-sonnet-5` to OpenAI and call the 404 a bug in the provider.
70
- session.model = readSettings().models?.[chosen.id] ?? chosen.defaultModel;
71
- session.config.providerId = chosen.id;
72
- session.config.model = session.model;
73
- if (session.model === "") {
74
- if (!(await modelsCommand(session, host, { announce: false, save: false }))) {
75
- restoreProvider(session, before);
76
- return false;
77
- }
78
- }
79
- else {
80
- const alignment = await alignEffort(session, host);
81
- if (!alignment.ok) {
82
- restoreProvider(session, before);
83
- return false;
84
- }
85
- }
86
- if (behavior.save !== false) {
87
- const saved = readSettings();
88
- const models = { ...saved.models };
89
- if (session.model !== "")
90
- models[chosen.id] = session.model;
91
- const patch = {
92
- provider: chosen.id,
93
- models,
94
- ...(session.config.effort === before.effort ? {} : { effort: session.config.effort }),
95
- };
96
- if (!(await saveDefaults(host, patch))) {
97
- restoreProvider(session, before);
98
- return false;
99
- }
100
- }
101
- if (behavior.announce !== false) {
102
- host.emit({
103
- kind: "notice",
104
- text: session.model === "" ? `provider · ${chosen.id} · pick a model` : `provider · ${chosen.id}`,
105
- tone: "info",
17
+ return;
18
+ let selected = Math.max(0, PROVIDERS.findIndex((provider) => provider.id === session.provider.id));
19
+ while (true) {
20
+ const index = await choose({
21
+ title: [],
22
+ options: PROVIDERS.map((provider) => ({
23
+ label: providerLabel(provider.id),
24
+ value: providerAccessHint(provider),
25
+ })),
26
+ index: selected,
106
27
  });
28
+ if (index === undefined)
29
+ return;
30
+ const provider = PROVIDERS[index];
31
+ if (provider === undefined)
32
+ return;
33
+ selected = index;
34
+ await manageProvider(provider, session, host);
35
+ // Esc closes only the nested provider flow. Ctrl+C also settles that
36
+ // picker, but aborts the command signal and must not reopen the parent.
37
+ throwIfAborted(host.signal);
107
38
  }
108
- return true;
109
39
  }
110
- /** The model menu, built from what the provider says it has right now. */
111
- export async function modelsCommand(session, host, behavior = {}) {
112
- // Before the network, not after: asking a provider for a list nothing can
113
- // be picked from is a request spent on a menu that will never open.
114
- const choose = chooser(host);
115
- if (choose === undefined)
116
- return false;
117
- const provider = session.provider;
118
- // Offer the key rather than only naming what is missing: the user came here
119
- // to pick a model, and "go and export a variable" is not an answer.
120
- const blocked = provider.blocked();
121
- if (blocked !== undefined) {
122
- await ensureProviderAuthentication(provider, session, host);
123
- const still = provider.blocked();
124
- if (still !== undefined) {
125
- host.emit({
126
- kind: "notice",
127
- text: `${providerLabel(provider.id)} still needs ${authenticationNeed(provider)}`,
128
- tone: "warn",
129
- });
130
- return false;
131
- }
132
- }
133
- host.status?.(`Asking ${provider.id}`);
134
- let ids;
135
- try {
136
- ids = await provider.models(host.signal, (status) => host.status?.(status));
137
- }
138
- catch (error) {
139
- host.emit({
140
- kind: "notice",
141
- text: providerFailure(provider, error, true),
142
- tone: "error",
143
- });
144
- return false;
145
- }
146
- finally {
147
- host.status?.(undefined);
148
- }
149
- if (ids.length === 0) {
150
- host.emit({ kind: "notice", text: `${provider.id} offers no models`, tone: "warn" });
151
- return false;
152
- }
153
- const index = await choose({
154
- title: heading("model", provider.id, session.palette),
155
- options: ids.map((id) => ({ label: id })),
156
- searchable: true,
157
- query: "",
158
- index: Math.max(0, ids.indexOf(session.model)),
159
- });
160
- if (index === undefined)
161
- return false;
162
- const chosen = ids[index];
163
- if (chosen === undefined)
164
- return false;
165
- const before = {
166
- model: session.model,
167
- configModel: session.config.model,
168
- effort: session.config.effort,
169
- };
170
- session.model = chosen;
171
- session.config.model = chosen;
172
- const alignment = await alignEffort(session, host);
173
- if (!alignment.ok) {
174
- session.model = before.model;
175
- session.config.model = before.configModel;
176
- session.config.effort = before.effort;
177
- return false;
178
- }
179
- if (behavior.save !== false) {
180
- const saved = readSettings();
181
- const models = { ...saved.models, [provider.id]: chosen };
182
- const patch = {
183
- models,
184
- ...(session.config.effort === before.effort ? {} : { effort: session.config.effort }),
185
- };
186
- if (!(await saveDefaults(host, patch))) {
187
- session.model = before.model;
188
- session.config.model = before.configModel;
189
- session.config.effort = before.effort;
190
- return false;
191
- }
40
+ export function providerAccessHint(provider) {
41
+ if (provider.id === "ollama") {
42
+ const connection = ollamaConnection();
43
+ if (connection.loopback)
44
+ return `${ollamaConnectionHint()} · no key needed`;
45
+ return `${ollamaConnectionHint()} · API key ${credentialSource(OLLAMA_KEY) ?? "missing"}`;
46
+ }
47
+ if (provider.auth.kind === "oauth")
48
+ return openAIAccountHint();
49
+ return `API key · ${credentialSource(provider.auth.keyVar) ?? "missing"}`;
50
+ }
51
+ async function manageProvider(provider, session, host) {
52
+ if (provider.id === "ollama") {
53
+ await ollamaProviderCommand(session, host);
54
+ return;
192
55
  }
193
- if (behavior.announce !== false) {
194
- host.emit({ kind: "notice", text: `model · ${chosen}`, tone: "info" });
56
+ if (provider.auth.kind === "oauth") {
57
+ await openAIAccountCommand(session, host);
58
+ return;
195
59
  }
196
- return true;
60
+ await apiKeyCommand(provider.auth.keyVar, providerLabel(provider.id), session, host);
197
61
  }
198
- function restoreProvider(session, before) {
199
- session.provider = before.provider;
200
- session.model = before.model;
201
- session.config.providerId = before.providerId;
202
- session.config.model = before.configModel;
203
- session.config.effort = before.effort;
204
- }
205
- async function alignEffort(session, host) {
206
- if (session.provider.efforts === undefined)
207
- return { ok: true };
208
- let supported;
209
- try {
210
- supported = await session.provider.efforts(session.model, host.signal, (status) => host.status?.(status));
62
+ async function ollamaProviderCommand(session, host) {
63
+ if (host.choose === undefined)
64
+ return;
65
+ const index = await host.choose({
66
+ title: heading("Ollama", "access and connection", session.palette),
67
+ options: [
68
+ { label: "connection", hint: ollamaConnectionHint() },
69
+ { label: "API key", hint: credentialSource(OLLAMA_KEY) ?? "missing" },
70
+ ],
71
+ index: 0,
72
+ });
73
+ if (index === 0) {
74
+ await ollamaConnectionSetting(session, host, (patch) => saveCommandSettings(host, patch));
211
75
  }
212
- catch (error) {
213
- host.emit({
214
- kind: "notice",
215
- text: providerFailure(session.provider, error, true),
216
- tone: "error",
217
- });
218
- return { ok: false };
76
+ else if (index === 1) {
77
+ await apiKeyCommand(OLLAMA_KEY, "Ollama", session, host);
219
78
  }
220
- const adjusted = compatibleEffort(session.config.effort, supported);
221
- if (adjusted === undefined || adjusted === session.config.effort)
222
- return { ok: true };
223
- session.config.effort = adjusted;
224
- return { ok: true, adjusted };
225
79
  }
226
80
  function chooser(host) {
227
81
  if (host.choose === undefined) {
@@ -229,19 +83,8 @@ function chooser(host) {
229
83
  }
230
84
  return host.choose;
231
85
  }
232
- async function saveDefaults(host, patch) {
233
- if (host.saveSettings === undefined)
234
- return true;
235
- try {
236
- await host.saveSettings(patch);
237
- return true;
238
- }
239
- catch (error) {
240
- host.emit({
241
- kind: "notice",
242
- text: `could not save settings · ${error.message}`,
243
- tone: "error",
244
- });
245
- return false;
246
- }
86
+ function throwIfAborted(signal) {
87
+ if (signal?.aborted !== true)
88
+ return;
89
+ throw signal.reason instanceof Error ? signal.reason : new Error("interrupted");
247
90
  }
@@ -1,10 +1,11 @@
1
1
  // Actionable provider failures for user-facing command and turn surfaces.
2
+ import { providerLabel } from "./provider-label.js";
2
3
  const CONNECTION_FAILURE = /network error calling|timed out waiting for response headers|response body was idle/i;
3
4
  export function providerFailure(provider, error, labelProvider = false) {
4
5
  if (provider.id === "ollama" && CONNECTION_FAILURE.test(error.message)) {
5
6
  return provider.location?.() === "local"
6
- ? "Ollama is not reachable on this computer · start Ollama or choose cloud in /settings"
7
- : "Ollama is not reachable · check its connection in /settings";
7
+ ? "Ollama is not reachable on this computer · start Ollama or choose cloud in /providers"
8
+ : "Ollama is not reachable · check its connection in /providers";
8
9
  }
9
- return labelProvider ? `${provider.id}: ${error.message}` : error.message;
10
+ return labelProvider ? `${providerLabel(provider.id)}: ${error.message}` : error.message;
10
11
  }
@@ -2,8 +2,8 @@
2
2
  export function providerLabel(id) {
3
3
  switch (id) {
4
4
  case "anthropic": return "Anthropic";
5
- case "openai": return "OpenAI";
6
- case "openai-codex": return "OpenAI Codex";
5
+ case "openai": return "OpenAI API";
6
+ case "openai-codex": return "ChatGPT";
7
7
  case "ollama": return "Ollama";
8
8
  default: return id === "" ? "Provider" : `${id[0]?.toUpperCase() ?? ""}${id.slice(1)}`;
9
9
  }
@@ -5,6 +5,7 @@
5
5
  // daemon. There is no default model — the catalogue is whatever the host has
6
6
  // pulled or the subscription grants — so the model has to be named with
7
7
  // --model.
8
+ import { requireSupportedEffort } from "../effort.js";
8
9
  import { postSse } from "./http.js";
9
10
  import { listModels } from "./catalog.js";
10
11
  import { keyFor } from "../credentials.js";
@@ -12,6 +13,8 @@ import { assembleOllama } from "./ollama-stream.js";
12
13
  import { OLLAMA_CLOUD_HOST, OLLAMA_LOCAL_HOST, ollamaConnectionKind, parseOllamaEndpoint, } from "./ollama-endpoint.js";
13
14
  import { fromWireReply, stopNotice, toWireMessages, toWireTool } from "./ollama-wire.js";
14
15
  const KEY = "OLLAMA_API_KEY";
16
+ // Ollama also accepts `none`; Jecode's product-wide reasoning floor is `low`.
17
+ const OLLAMA_EFFORTS = ["low", "medium", "high"];
15
18
  let configuredHost;
16
19
  /** Set the endpoint selected for this process. Undefined restores key-aware inference. */
17
20
  export function configureOllama(host) {
@@ -46,7 +49,7 @@ export const ollama = {
46
49
  return listModels(`${at.baseUrl}/v1/models`, headers(at), signal, onStatus);
47
50
  },
48
51
  async efforts() {
49
- return [];
52
+ return OLLAMA_EFFORTS;
50
53
  },
51
54
  location: () => {
52
55
  try {
@@ -58,13 +61,15 @@ export const ollama = {
58
61
  },
59
62
  async send(req) {
60
63
  const at = endpoint();
61
- // `effort` has no equivalent in this shape and is dropped rather than
62
- // guessed at depth on these models is a property of the model chosen.
64
+ const effort = requireSupportedEffort(req.model, req.effort, OLLAMA_EFFORTS);
65
+ // The OpenAI-compatible endpoint accepts this vocabulary for thinking
66
+ // models. Invalid levels are rejected locally instead of being rewritten.
63
67
  const events = await postSse(`${at.baseUrl}/v1/chat/completions`, headers(at), {
64
68
  model: req.model,
65
69
  messages: toWireMessages(req.system, req.messages),
66
70
  tools: req.tools.map(toWireTool),
67
71
  max_tokens: req.maxTokens,
72
+ reasoning_effort: effort,
68
73
  stream: true,
69
74
  }, req.signal, req.onStatus);
70
75
  const reply = await assembleOllama(events, req.onStream);