@giovannijecha/jecode 0.1.5-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +264 -0
- package/bin/jecode.js +11 -0
- package/dist/atomic.js +78 -0
- package/dist/batch-view.js +17 -0
- package/dist/batch.js +100 -0
- package/dist/cli-info.js +40 -0
- package/dist/commands.js +171 -0
- package/dist/config.js +97 -0
- package/dist/controller.js +90 -0
- package/dist/credential-commands.js +134 -0
- package/dist/credential-safety.js +86 -0
- package/dist/credentials.js +124 -0
- package/dist/main.js +7 -0
- package/dist/ollama-settings-command.js +75 -0
- package/dist/prompt.js +29 -0
- package/dist/provider-commands.js +236 -0
- package/dist/provider-errors.js +10 -0
- package/dist/providers/anthropic-stream.js +111 -0
- package/dist/providers/anthropic-wire.js +79 -0
- package/dist/providers/anthropic.js +77 -0
- package/dist/providers/catalog.js +37 -0
- package/dist/providers/http.js +219 -0
- package/dist/providers/index.js +18 -0
- package/dist/providers/ollama-endpoint.js +40 -0
- package/dist/providers/ollama-stream.js +60 -0
- package/dist/providers/ollama-wire.js +95 -0
- package/dist/providers/ollama.js +87 -0
- package/dist/providers/openai-stream.js +48 -0
- package/dist/providers/openai-wire.js +112 -0
- package/dist/providers/openai.js +70 -0
- package/dist/providers/sse.js +81 -0
- package/dist/providers/stream-limits.js +11 -0
- package/dist/session.js +3 -0
- package/dist/settings-command.js +202 -0
- package/dist/settings.js +98 -0
- package/dist/start.js +47 -0
- package/dist/tools/args.js +38 -0
- package/dist/tools/fs.js +277 -0
- package/dist/tools/index.js +39 -0
- package/dist/tools/paths.js +122 -0
- package/dist/tools/search.js +213 -0
- package/dist/tools/shell.js +139 -0
- package/dist/tools/text-boundary.js +102 -0
- package/dist/tools/types.js +1 -0
- package/dist/transcript-export.js +11 -0
- package/dist/transcript.js +49 -0
- package/dist/tui/activity.js +11 -0
- package/dist/tui/app-input.js +155 -0
- package/dist/tui/app-state.js +17 -0
- package/dist/tui/app-workflows.js +105 -0
- package/dist/tui/app.js +215 -0
- package/dist/tui/approve.js +67 -0
- package/dist/tui/blocks.js +23 -0
- package/dist/tui/complete.js +54 -0
- package/dist/tui/components/command-menu.js +17 -0
- package/dist/tui/components/composer.js +47 -0
- package/dist/tui/components/dock.js +10 -0
- package/dist/tui/components/footer.js +21 -0
- package/dist/tui/components/menu.js +38 -0
- package/dist/tui/components/messages.js +43 -0
- package/dist/tui/components/misc.js +22 -0
- package/dist/tui/components/status.js +45 -0
- package/dist/tui/components/tool.js +85 -0
- package/dist/tui/components/types.js +1 -0
- package/dist/tui/editor.js +105 -0
- package/dist/tui/feedback.js +74 -0
- package/dist/tui/field.js +60 -0
- package/dist/tui/frame.js +33 -0
- package/dist/tui/input.js +52 -0
- package/dist/tui/keys.js +177 -0
- package/dist/tui/modal.js +24 -0
- package/dist/tui/overlay.js +93 -0
- package/dist/tui/picker.js +99 -0
- package/dist/tui/screen.js +94 -0
- package/dist/tui/scroll.js +7 -0
- package/dist/tui/session-view.js +49 -0
- package/dist/tui/transcript-view.js +134 -0
- package/dist/tui/turn.js +255 -0
- package/dist/tui/view.js +88 -0
- package/dist/tui/workspace.js +59 -0
- package/dist/types.js +9 -0
- package/dist/ui/diff.js +109 -0
- package/dist/ui/highlight.js +158 -0
- package/dist/ui/inline.js +39 -0
- package/dist/ui/markdown.js +147 -0
- package/dist/ui/render.js +232 -0
- package/dist/ui/table.js +127 -0
- package/dist/ui/terminal-text.js +42 -0
- package/dist/ui/theme.js +25 -0
- package/dist/ui/width.js +196 -0
- package/dist/usage.js +30 -0
- package/dist/user-data.js +29 -0
- package/package.json +56 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Where an API key comes from, and where one goes when the user types it in.
|
|
2
|
+
//
|
|
3
|
+
// Three layers, looked at in this order: the environment, then whatever this
|
|
4
|
+
// session was handed, then the saved file. The environment winning is the
|
|
5
|
+
// load-bearing part — a key exported in the shell is the one the user is
|
|
6
|
+
// looking at, and a stale saved key quietly overriding it is the worst kind of
|
|
7
|
+
// bug to be on the wrong side of.
|
|
8
|
+
//
|
|
9
|
+
// The file lives under ~/.jecode and never in this repo. A
|
|
10
|
+
// secret in the working tree is one `git add -A` from being published, which
|
|
11
|
+
// is why "not in the repo" is a rule and not a preference.
|
|
12
|
+
import { chmod, mkdir } from "node:fs/promises";
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import { atomicWrite } from "./atomic.js";
|
|
16
|
+
import { legacyUserDataPath, userDataLabel, userDataPath } from "./user-data.js";
|
|
17
|
+
/** Keys this session was given but not asked to keep. Dies with the window. */
|
|
18
|
+
const held = new Map();
|
|
19
|
+
/** The saved file, read once. `undefined` until the first look at it. */
|
|
20
|
+
let saved;
|
|
21
|
+
export function keyFor(name) {
|
|
22
|
+
return use(process.env[name]) ?? use(held.get(name)) ?? use(fromDisk()[name]);
|
|
23
|
+
}
|
|
24
|
+
export function credentialSource(name) {
|
|
25
|
+
if (use(process.env[name]) !== undefined)
|
|
26
|
+
return "environment";
|
|
27
|
+
if (use(held.get(name)) !== undefined)
|
|
28
|
+
return "session";
|
|
29
|
+
return use(fromDisk()[name]) === undefined ? undefined : "saved";
|
|
30
|
+
}
|
|
31
|
+
/** Values that must never survive in shell output, regardless of their source. */
|
|
32
|
+
export function credentialValues() {
|
|
33
|
+
const stored = fromDisk();
|
|
34
|
+
const names = new Set([...held.keys(), ...Object.keys(stored)]);
|
|
35
|
+
const values = new Set([...held.values(), ...Object.values(stored)]);
|
|
36
|
+
for (const name of names) {
|
|
37
|
+
const environment = use(process.env[name]);
|
|
38
|
+
if (environment !== undefined)
|
|
39
|
+
values.add(environment);
|
|
40
|
+
}
|
|
41
|
+
return [...values].filter((value) => use(value) !== undefined);
|
|
42
|
+
}
|
|
43
|
+
export function hasSaved(name) {
|
|
44
|
+
return use(fromDisk()[name]) !== undefined;
|
|
45
|
+
}
|
|
46
|
+
/** Take a key for this session only. Nothing is written anywhere. */
|
|
47
|
+
export function hold(name, value) {
|
|
48
|
+
held.set(name, value);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Take a key and write it down, returning the path it went to.
|
|
52
|
+
*
|
|
53
|
+
* Owner-only permissions on a directory that did not exist a moment ago: the
|
|
54
|
+
* mode is set at creation rather than fixed afterwards, because between the
|
|
55
|
+
* two there is a window where the file is readable and the key is in it.
|
|
56
|
+
* Windows ignores the mode and relies on the profile directory's own ACL.
|
|
57
|
+
*/
|
|
58
|
+
export async function keep(name, value) {
|
|
59
|
+
const file = storePath();
|
|
60
|
+
const all = { ...fromDisk(), [name]: value };
|
|
61
|
+
await persist(file, all);
|
|
62
|
+
hold(name, value);
|
|
63
|
+
saved = all;
|
|
64
|
+
return file;
|
|
65
|
+
}
|
|
66
|
+
/** Remove only the saved copy. An environment or session value is untouched. */
|
|
67
|
+
export async function forgetSaved(name) {
|
|
68
|
+
const all = { ...fromDisk() };
|
|
69
|
+
if (use(all[name]) === undefined)
|
|
70
|
+
return false;
|
|
71
|
+
delete all[name];
|
|
72
|
+
const file = storePath();
|
|
73
|
+
await persist(file, all);
|
|
74
|
+
saved = all;
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
export function storePath() {
|
|
78
|
+
return userDataPath("credentials.json");
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The store path, written the way the user would write it.
|
|
82
|
+
*
|
|
83
|
+
* This label is compact enough for both menu rows and transient feedback. The
|
|
84
|
+
* canonical path remains discoverable without pushing useful copy off-screen.
|
|
85
|
+
*/
|
|
86
|
+
export function storeLabel() {
|
|
87
|
+
return userDataLabel("credentials.json");
|
|
88
|
+
}
|
|
89
|
+
/** Forget what was read, so the next look goes back to disk. For tests. */
|
|
90
|
+
export function reload() {
|
|
91
|
+
saved = undefined;
|
|
92
|
+
held.clear();
|
|
93
|
+
}
|
|
94
|
+
function fromDisk() {
|
|
95
|
+
if (saved !== undefined)
|
|
96
|
+
return saved;
|
|
97
|
+
const current = readStore(storePath());
|
|
98
|
+
const legacy = legacyUserDataPath("credentials.json");
|
|
99
|
+
saved = current ?? (legacy === undefined ? undefined : readStore(legacy)) ?? {};
|
|
100
|
+
return saved;
|
|
101
|
+
}
|
|
102
|
+
function readStore(file) {
|
|
103
|
+
try {
|
|
104
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
105
|
+
// Anything that is not a string is not a key, whatever the file says.
|
|
106
|
+
return Object.fromEntries(Object.entries(parsed).filter((entry) => typeof entry[1] === "string"));
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
// Only a missing canonical file falls through to the legacy location. A
|
|
110
|
+
// malformed new store must not resurrect an older credential silently.
|
|
111
|
+
return error.code === "ENOENT" ? undefined : {};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async function persist(file, values) {
|
|
115
|
+
const directory = path.dirname(file);
|
|
116
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
117
|
+
if (process.platform !== "win32")
|
|
118
|
+
await chmod(directory, 0o700);
|
|
119
|
+
await atomicWrite(file, `${JSON.stringify(values, null, 2)}\n`, { mode: 0o600 });
|
|
120
|
+
}
|
|
121
|
+
/** An empty variable is an unset variable — an exported "" is not a key. */
|
|
122
|
+
function use(value) {
|
|
123
|
+
return value === undefined || value === "" ? undefined : value;
|
|
124
|
+
}
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Process entry point. The testable bootstrap lives in start.ts.
|
|
2
|
+
import { start } from "./start.js";
|
|
3
|
+
import { terminalText } from "./ui/terminal-text.js";
|
|
4
|
+
start().catch((error) => {
|
|
5
|
+
process.stderr.write(`jecode: ${terminalText(error.message)}\n`);
|
|
6
|
+
process.exitCode = 1;
|
|
7
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Ollama connection choices inside the persistent settings hub.
|
|
2
|
+
import { askForKey } from "./credential-commands.js";
|
|
3
|
+
import { keyFor } from "./credentials.js";
|
|
4
|
+
import { of } from "./tui/editor.js";
|
|
5
|
+
import { heading } from "./tui/picker.js";
|
|
6
|
+
import { OLLAMA_CLOUD_HOST, OLLAMA_LOCAL_HOST, parseOllamaEndpoint, } from "./providers/ollama-endpoint.js";
|
|
7
|
+
import { configureOllama, ollamaConnection } from "./providers/ollama.js";
|
|
8
|
+
const KEY = "OLLAMA_API_KEY";
|
|
9
|
+
export function ollamaConnectionHint() {
|
|
10
|
+
const connection = ollamaConnection();
|
|
11
|
+
const origin = new URL(connection.baseUrl).host;
|
|
12
|
+
const automatic = connection.inferred ? " · automatic" : "";
|
|
13
|
+
if (connection.kind === "local")
|
|
14
|
+
return `local · this computer${automatic}`;
|
|
15
|
+
return `${connection.kind} · ${origin}${automatic}`;
|
|
16
|
+
}
|
|
17
|
+
export async function ollamaConnectionSetting(session, host, persist) {
|
|
18
|
+
if (host.choose === undefined)
|
|
19
|
+
return;
|
|
20
|
+
const current = ollamaConnection();
|
|
21
|
+
const index = await host.choose({
|
|
22
|
+
title: heading("Ollama connection", "where Ollama requests run", session.palette),
|
|
23
|
+
right: "↑↓ enter · esc back",
|
|
24
|
+
options: [
|
|
25
|
+
{ label: "cloud", hint: "ollama.com · API key" },
|
|
26
|
+
{ label: "local", hint: "this computer · no API key" },
|
|
27
|
+
{ label: "custom", hint: "HTTPS or loopback endpoint" },
|
|
28
|
+
],
|
|
29
|
+
index: current.kind === "cloud" ? 0 : current.kind === "local" ? 1 : 2,
|
|
30
|
+
});
|
|
31
|
+
if (index === undefined)
|
|
32
|
+
return;
|
|
33
|
+
let nextHost;
|
|
34
|
+
if (index === 0)
|
|
35
|
+
nextHost = OLLAMA_CLOUD_HOST;
|
|
36
|
+
else if (index === 1)
|
|
37
|
+
nextHost = OLLAMA_LOCAL_HOST;
|
|
38
|
+
else {
|
|
39
|
+
const custom = await customEndpoint(session, host, current.kind === "custom" ? current.baseUrl : "https://");
|
|
40
|
+
if (custom === undefined)
|
|
41
|
+
return;
|
|
42
|
+
nextHost = custom;
|
|
43
|
+
}
|
|
44
|
+
const endpoint = parseOllamaEndpoint(nextHost);
|
|
45
|
+
if (!endpoint.loopback && keyFor(KEY) === undefined) {
|
|
46
|
+
const accepted = await askForKey(KEY, host, session.palette);
|
|
47
|
+
if (!accepted)
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (!(await persist({ ollamaHost: endpoint.baseUrl })))
|
|
51
|
+
return;
|
|
52
|
+
configureOllama(endpoint.baseUrl);
|
|
53
|
+
session.config.ollamaHost = endpoint.baseUrl;
|
|
54
|
+
}
|
|
55
|
+
async function customEndpoint(session, host, initial) {
|
|
56
|
+
if (host.type === undefined)
|
|
57
|
+
return undefined;
|
|
58
|
+
const field = {
|
|
59
|
+
title: heading("Ollama endpoint", "HTTPS or exact loopback URL", session.palette),
|
|
60
|
+
right: "enter save · esc back",
|
|
61
|
+
editor: of(initial),
|
|
62
|
+
secret: false,
|
|
63
|
+
note: "Remote endpoints must use HTTPS. API keys are stored separately.",
|
|
64
|
+
};
|
|
65
|
+
const value = await host.type(field);
|
|
66
|
+
if (value === undefined)
|
|
67
|
+
return undefined;
|
|
68
|
+
try {
|
|
69
|
+
return parseOllamaEndpoint(value).baseUrl;
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
host.emit({ kind: "notice", text: error.message, tone: "error" });
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The system prompt. Its own module because it is content, not logic — it
|
|
2
|
+
// gets rewritten far more often than the loop that carries it.
|
|
3
|
+
export function systemPrompt(config) {
|
|
4
|
+
return [
|
|
5
|
+
"You are jecode, a coding agent working in a terminal alongside the user.",
|
|
6
|
+
"",
|
|
7
|
+
`Workspace root: ${config.root}`,
|
|
8
|
+
`Platform: ${process.platform}`,
|
|
9
|
+
"",
|
|
10
|
+
"How you work:",
|
|
11
|
+
"- Read before you write. Use find_files, search_text, read_file, and list_dir",
|
|
12
|
+
" to ground yourself in the actual code rather than assuming what it contains.",
|
|
13
|
+
"- Prefer edit_file over write_file for existing files; write_file replaces",
|
|
14
|
+
" the whole file and loses anything you did not reproduce.",
|
|
15
|
+
"- Every path you pass to a tool is relative to the workspace root, and you",
|
|
16
|
+
" cannot reach outside it.",
|
|
17
|
+
"- Batch independent tool calls in one turn; the results come back together.",
|
|
18
|
+
"- After changing code, verify it — run the tests or the typechecker with",
|
|
19
|
+
" run_command rather than declaring success untested.",
|
|
20
|
+
"- When a tool fails, read the error and correct the call. You get another turn.",
|
|
21
|
+
"",
|
|
22
|
+
"How you answer:",
|
|
23
|
+
"- Be concise and concrete. Lead with the result, skip the preamble.",
|
|
24
|
+
"- Say plainly what you did and what you did not do. If something is still",
|
|
25
|
+
" broken or unverified, say so rather than papering over it.",
|
|
26
|
+
"- Ask the user when a decision is genuinely theirs; otherwise make the call",
|
|
27
|
+
" and keep going.",
|
|
28
|
+
].join("\n");
|
|
29
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Provider, model, and setup command flows.
|
|
2
|
+
import { heading } from "./tui/picker.js";
|
|
3
|
+
import { PROVIDERS } from "./providers/index.js";
|
|
4
|
+
import { readSettings } from "./settings.js";
|
|
5
|
+
import { askForKey } from "./credential-commands.js";
|
|
6
|
+
import { providerFailure } from "./provider-errors.js";
|
|
7
|
+
/**
|
|
8
|
+
* The provider menu.
|
|
9
|
+
*
|
|
10
|
+
* Every provider is offered, including the ones that cannot run: the reason
|
|
11
|
+
* one is unusable — the variable it wants, by name — is worth more on screen
|
|
12
|
+
* than the row would be worth hidden. A blocked choice opens the same masked
|
|
13
|
+
* credential flow used by setup and settings, and cancellation leaves the old choice.
|
|
14
|
+
*/
|
|
15
|
+
export async function providersCommand(session, host, behavior = {}) {
|
|
16
|
+
const choose = chooser(host);
|
|
17
|
+
if (choose === undefined)
|
|
18
|
+
return false;
|
|
19
|
+
const options = PROVIDERS.map((provider) => ({
|
|
20
|
+
label: provider.id,
|
|
21
|
+
hint: provider.blocked() ?? "ready",
|
|
22
|
+
}));
|
|
23
|
+
const at = PROVIDERS.findIndex((provider) => provider.id === session.provider.id);
|
|
24
|
+
const index = await choose({
|
|
25
|
+
title: heading("provider", "where the next turn runs", session.palette),
|
|
26
|
+
right: "↑↓ enter",
|
|
27
|
+
options,
|
|
28
|
+
index: Math.max(0, at),
|
|
29
|
+
});
|
|
30
|
+
if (index === undefined)
|
|
31
|
+
return false;
|
|
32
|
+
const chosen = PROVIDERS[index];
|
|
33
|
+
if (chosen === undefined)
|
|
34
|
+
return false;
|
|
35
|
+
// Picking the provider already in use is not a no-op when it cannot run:
|
|
36
|
+
// it is how the user asks to fix the reason it cannot.
|
|
37
|
+
if (chosen.id === session.provider.id) {
|
|
38
|
+
const blocked = chosen.blocked();
|
|
39
|
+
if (blocked !== undefined) {
|
|
40
|
+
if (!isCredentialBlocker(chosen, blocked)) {
|
|
41
|
+
host.emit({ kind: "notice", text: blocked, tone: "error" });
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
await askForKey(chosen.keyVar, host, session.palette);
|
|
45
|
+
if (chosen.blocked() !== undefined)
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
// A provider that cannot run is worth one offer to fix it, here, rather
|
|
51
|
+
// than a note telling the user to leave and export something.
|
|
52
|
+
const blocked = chosen.blocked();
|
|
53
|
+
if (blocked !== undefined) {
|
|
54
|
+
if (!isCredentialBlocker(chosen, blocked)) {
|
|
55
|
+
host.emit({ kind: "notice", text: blocked, tone: "error" });
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
await askForKey(chosen.keyVar, host, session.palette);
|
|
59
|
+
const still = chosen.blocked();
|
|
60
|
+
if (still !== undefined) {
|
|
61
|
+
host.emit({
|
|
62
|
+
kind: "notice",
|
|
63
|
+
text: `${providerName(chosen.id)} still needs an API key · provider unchanged`,
|
|
64
|
+
tone: "warn",
|
|
65
|
+
});
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const before = {
|
|
70
|
+
provider: session.provider,
|
|
71
|
+
model: session.model,
|
|
72
|
+
providerId: session.config.providerId,
|
|
73
|
+
configModel: session.config.model,
|
|
74
|
+
};
|
|
75
|
+
session.provider = chosen;
|
|
76
|
+
// The model belonged to the old provider. Carrying it across would send
|
|
77
|
+
// `claude-sonnet-5` to OpenAI and call the 404 a bug in the provider.
|
|
78
|
+
session.model = readSettings().models?.[chosen.id] ?? chosen.defaultModel;
|
|
79
|
+
session.config.providerId = chosen.id;
|
|
80
|
+
session.config.model = session.model;
|
|
81
|
+
if (behavior.save !== false) {
|
|
82
|
+
const saved = readSettings();
|
|
83
|
+
const models = { ...saved.models };
|
|
84
|
+
if (session.model !== "")
|
|
85
|
+
models[chosen.id] = session.model;
|
|
86
|
+
if (!(await saveDefaults(host, { provider: chosen.id, models }))) {
|
|
87
|
+
session.provider = before.provider;
|
|
88
|
+
session.model = before.model;
|
|
89
|
+
session.config.providerId = before.providerId;
|
|
90
|
+
session.config.model = before.configModel;
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (behavior.announce !== false) {
|
|
95
|
+
host.emit({
|
|
96
|
+
kind: "notice",
|
|
97
|
+
text: session.model === "" ? `${chosen.id} — pick a model with /models` : `${chosen.id} · ${session.model}`,
|
|
98
|
+
tone: "info",
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
/** The model menu, built from what the provider says it has right now. */
|
|
104
|
+
export async function modelsCommand(session, host, behavior = {}) {
|
|
105
|
+
// Before the network, not after: asking a provider for a list nothing can
|
|
106
|
+
// be picked from is a request spent on a menu that will never open.
|
|
107
|
+
const choose = chooser(host);
|
|
108
|
+
if (choose === undefined)
|
|
109
|
+
return false;
|
|
110
|
+
const provider = session.provider;
|
|
111
|
+
// Offer the key rather than only naming what is missing: the user came here
|
|
112
|
+
// to pick a model, and "go and export a variable" is not an answer.
|
|
113
|
+
const blocked = provider.blocked();
|
|
114
|
+
if (blocked !== undefined) {
|
|
115
|
+
if (!isCredentialBlocker(provider, blocked)) {
|
|
116
|
+
host.emit({ kind: "notice", text: blocked, tone: "error" });
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
await askForKey(provider.keyVar, host, session.palette);
|
|
120
|
+
const still = provider.blocked();
|
|
121
|
+
if (still !== undefined) {
|
|
122
|
+
host.emit({
|
|
123
|
+
kind: "notice",
|
|
124
|
+
text: `${providerName(provider.id)} still needs an API key`,
|
|
125
|
+
tone: "warn",
|
|
126
|
+
});
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
host.status?.(`Asking ${provider.id}`);
|
|
131
|
+
let ids;
|
|
132
|
+
try {
|
|
133
|
+
ids = await provider.models(host.signal, (status) => host.status?.(status));
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
host.emit({
|
|
137
|
+
kind: "notice",
|
|
138
|
+
text: providerFailure(provider, error, true),
|
|
139
|
+
tone: "error",
|
|
140
|
+
});
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
host.status?.(undefined);
|
|
145
|
+
}
|
|
146
|
+
if (ids.length === 0) {
|
|
147
|
+
host.emit({ kind: "notice", text: `${provider.id} offers no models`, tone: "warn" });
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
const index = await choose({
|
|
151
|
+
title: heading("model", provider.id, session.palette),
|
|
152
|
+
right: "↑↓ enter",
|
|
153
|
+
options: ids.map((id) => ({ label: id })),
|
|
154
|
+
searchable: true,
|
|
155
|
+
query: "",
|
|
156
|
+
index: Math.max(0, ids.indexOf(session.model)),
|
|
157
|
+
});
|
|
158
|
+
if (index === undefined)
|
|
159
|
+
return false;
|
|
160
|
+
const chosen = ids[index];
|
|
161
|
+
if (chosen === undefined)
|
|
162
|
+
return false;
|
|
163
|
+
const before = { model: session.model, configModel: session.config.model };
|
|
164
|
+
session.model = chosen;
|
|
165
|
+
session.config.model = chosen;
|
|
166
|
+
if (behavior.save !== false) {
|
|
167
|
+
const saved = readSettings();
|
|
168
|
+
const models = { ...saved.models, [provider.id]: chosen };
|
|
169
|
+
if (!(await saveDefaults(host, { models }))) {
|
|
170
|
+
session.model = before.model;
|
|
171
|
+
session.config.model = before.configModel;
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (behavior.announce !== false) {
|
|
176
|
+
host.emit({ kind: "notice", text: `${provider.id} · ${chosen}`, tone: "info" });
|
|
177
|
+
}
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
/** Make the provider selected by flags/environment usable without leaving the TUI. */
|
|
181
|
+
export async function setupCommand(session, host) {
|
|
182
|
+
const blocked = session.provider.blocked();
|
|
183
|
+
if (blocked !== undefined) {
|
|
184
|
+
if (!isCredentialBlocker(session.provider, blocked)) {
|
|
185
|
+
host.emit({ kind: "notice", text: blocked, tone: "error" });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const accepted = await askForKey(session.provider.keyVar, host, session.palette);
|
|
189
|
+
if (!accepted || session.provider.blocked() !== undefined) {
|
|
190
|
+
host.emit({
|
|
191
|
+
kind: "notice",
|
|
192
|
+
text: `${providerName(session.provider.id)} still needs an API key · /setup`,
|
|
193
|
+
tone: "warn",
|
|
194
|
+
});
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (session.model === "") {
|
|
199
|
+
await modelsCommand(session, host);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
host.emit({
|
|
203
|
+
kind: "notice",
|
|
204
|
+
text: `${session.provider.id} · ${session.model} · ${session.provider.location?.() ?? "cloud"} · ready`,
|
|
205
|
+
tone: "info",
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
/** The way to put a menu up, or nothing — and the reason, already said. */
|
|
209
|
+
function chooser(host) {
|
|
210
|
+
if (host.choose === undefined) {
|
|
211
|
+
host.emit({ kind: "notice", text: "that command needs the screen", tone: "warn" });
|
|
212
|
+
}
|
|
213
|
+
return host.choose;
|
|
214
|
+
}
|
|
215
|
+
function providerName(id) {
|
|
216
|
+
return id === "" ? "Provider" : `${id[0]?.toUpperCase() ?? ""}${id.slice(1)}`;
|
|
217
|
+
}
|
|
218
|
+
function isCredentialBlocker(provider, blocked) {
|
|
219
|
+
return blocked.startsWith(`${provider.keyVar} `);
|
|
220
|
+
}
|
|
221
|
+
async function saveDefaults(host, patch) {
|
|
222
|
+
if (host.saveSettings === undefined)
|
|
223
|
+
return true;
|
|
224
|
+
try {
|
|
225
|
+
await host.saveSettings(patch);
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
host.emit({
|
|
230
|
+
kind: "notice",
|
|
231
|
+
text: `could not save settings · ${error.message}`,
|
|
232
|
+
tone: "error",
|
|
233
|
+
});
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Actionable provider failures for user-facing command and turn surfaces.
|
|
2
|
+
const CONNECTION_FAILURE = /network error calling|timed out waiting for response headers|response body was idle/i;
|
|
3
|
+
export function providerFailure(provider, error, labelProvider = false) {
|
|
4
|
+
if (provider.id === "ollama" && CONNECTION_FAILURE.test(error.message)) {
|
|
5
|
+
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";
|
|
8
|
+
}
|
|
9
|
+
return labelProvider ? `${provider.id}: ${error.message}` : error.message;
|
|
10
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Reassembling an Anthropic response from its event stream.
|
|
2
|
+
//
|
|
3
|
+
// The stream never delivers a finished message — `message_stop` carries
|
|
4
|
+
// nothing — so the blocks have to be rebuilt here, index by index, into
|
|
5
|
+
// exactly the array a non-streamed response would have returned. That matters
|
|
6
|
+
// beyond display: thinking blocks carry a signature and must be echoed back
|
|
7
|
+
// byte-for-byte on the next request.
|
|
8
|
+
import { addBounded, MAX_TOOL_ARGUMENT_CHARS } from "./stream-limits.js";
|
|
9
|
+
export async function assembleAnthropic(events, onStream) {
|
|
10
|
+
const blocks = new Map();
|
|
11
|
+
const partialJson = new Map();
|
|
12
|
+
const sizes = { toolArguments: 0 };
|
|
13
|
+
let stopReason;
|
|
14
|
+
let stopDetails;
|
|
15
|
+
let usage;
|
|
16
|
+
for await (const raw of events) {
|
|
17
|
+
const event = raw;
|
|
18
|
+
switch (event.type) {
|
|
19
|
+
case "message_start":
|
|
20
|
+
usage = mergeUsage(usage, event.message?.usage);
|
|
21
|
+
break;
|
|
22
|
+
case "content_block_start": {
|
|
23
|
+
if (typeof event.index === "number" && event.content_block !== undefined) {
|
|
24
|
+
blocks.set(event.index, { ...event.content_block });
|
|
25
|
+
}
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
case "content_block_delta": {
|
|
29
|
+
if (typeof event.index !== "number")
|
|
30
|
+
break;
|
|
31
|
+
applyDelta(blocks, partialJson, sizes, event.index, event.delta, onStream);
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
case "content_block_stop": {
|
|
35
|
+
if (typeof event.index !== "number")
|
|
36
|
+
break;
|
|
37
|
+
const pending = partialJson.get(event.index);
|
|
38
|
+
const block = blocks.get(event.index);
|
|
39
|
+
if (pending !== undefined && block !== undefined) {
|
|
40
|
+
block.input = parseJsonObject(pending);
|
|
41
|
+
partialJson.delete(event.index);
|
|
42
|
+
}
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
case "message_delta": {
|
|
46
|
+
stopReason = event.delta?.stop_reason ?? stopReason;
|
|
47
|
+
stopDetails = event.delta?.stop_details ?? stopDetails;
|
|
48
|
+
usage = mergeUsage(usage, event.usage);
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
case "error": {
|
|
52
|
+
throw new Error(`anthropic stream error: ${event.error?.message ?? "unspecified"}`);
|
|
53
|
+
}
|
|
54
|
+
default:
|
|
55
|
+
break; // message_start, ping, message_stop: nothing to accumulate
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const content = [...blocks.entries()]
|
|
59
|
+
.sort(([a], [b]) => a - b)
|
|
60
|
+
.map(([, block]) => block);
|
|
61
|
+
return { content, stop_reason: stopReason, stop_details: stopDetails, usage };
|
|
62
|
+
}
|
|
63
|
+
function mergeUsage(before, after) {
|
|
64
|
+
return after === undefined ? before : { ...before, ...after };
|
|
65
|
+
}
|
|
66
|
+
function applyDelta(blocks, partialJson, sizes, index, delta, onStream) {
|
|
67
|
+
const block = blocks.get(index);
|
|
68
|
+
if (delta === undefined || block === undefined)
|
|
69
|
+
return;
|
|
70
|
+
switch (delta.type) {
|
|
71
|
+
case "text_delta":
|
|
72
|
+
if (typeof delta.text !== "string")
|
|
73
|
+
return;
|
|
74
|
+
block.text = `${block.text ?? ""}${delta.text}`;
|
|
75
|
+
onStream?.({ kind: "text", text: delta.text });
|
|
76
|
+
return;
|
|
77
|
+
case "thinking_delta":
|
|
78
|
+
if (typeof delta.thinking !== "string")
|
|
79
|
+
return;
|
|
80
|
+
block.thinking = `${block.thinking ?? ""}${delta.thinking}`;
|
|
81
|
+
onStream?.({ kind: "thinking", text: delta.thinking });
|
|
82
|
+
return;
|
|
83
|
+
case "signature_delta":
|
|
84
|
+
if (typeof delta.signature === "string")
|
|
85
|
+
block.signature = delta.signature;
|
|
86
|
+
return;
|
|
87
|
+
case "input_json_delta":
|
|
88
|
+
if (typeof delta.partial_json !== "string")
|
|
89
|
+
return;
|
|
90
|
+
sizes.toolArguments = addBounded(sizes.toolArguments, delta.partial_json.length, MAX_TOOL_ARGUMENT_CHARS, "streamed tool arguments");
|
|
91
|
+
partialJson.set(index, `${partialJson.get(index) ?? ""}${delta.partial_json}`);
|
|
92
|
+
return;
|
|
93
|
+
default:
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Tool arguments arrive as a stream of JSON fragments. An empty accumulation
|
|
98
|
+
// is a call with no arguments, not a malformed one.
|
|
99
|
+
function parseJsonObject(text) {
|
|
100
|
+
if (text.trim() === "")
|
|
101
|
+
return {};
|
|
102
|
+
try {
|
|
103
|
+
const parsed = JSON.parse(text);
|
|
104
|
+
return typeof parsed === "object" && parsed !== null
|
|
105
|
+
? parsed
|
|
106
|
+
: {};
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return {};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Translation between the normalized vocabulary and the Anthropic wire shape.
|
|
2
|
+
// Pure functions, no I/O — which is what makes them testable without a key.
|
|
3
|
+
export function toWireTool(tool) {
|
|
4
|
+
return { name: tool.name, description: tool.description, input_schema: tool.input };
|
|
5
|
+
}
|
|
6
|
+
export function toWireMessage(message) {
|
|
7
|
+
if (message.rawFrom === "anthropic" && message.raw !== undefined) {
|
|
8
|
+
return { role: message.role, content: message.raw };
|
|
9
|
+
}
|
|
10
|
+
return { role: message.role, content: message.content.map(toWireBlock) };
|
|
11
|
+
}
|
|
12
|
+
function toWireBlock(block) {
|
|
13
|
+
if (block.kind === "text")
|
|
14
|
+
return { type: "text", text: block.text };
|
|
15
|
+
if (block.kind === "tool_call") {
|
|
16
|
+
return { type: "tool_use", id: block.id, name: block.name, input: block.input };
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
type: "tool_result",
|
|
20
|
+
tool_use_id: block.id,
|
|
21
|
+
content: block.output,
|
|
22
|
+
is_error: block.isError,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Why the model stopped, when that is something the user needs told. Returns
|
|
27
|
+
* undefined for an ordinary ending.
|
|
28
|
+
*
|
|
29
|
+
* `stop_details` is populated for refusals and null for every other stop
|
|
30
|
+
* reason, so it has to be read behind the check, not before it.
|
|
31
|
+
*/
|
|
32
|
+
export function stopNotice(data) {
|
|
33
|
+
if (data.stop_reason === "refusal") {
|
|
34
|
+
const category = data.stop_details?.category ?? "unspecified";
|
|
35
|
+
const why = data.stop_details?.explanation ?? "no explanation given";
|
|
36
|
+
return `[refused: ${category}] ${why}`;
|
|
37
|
+
}
|
|
38
|
+
if (data.stop_reason === "max_tokens") {
|
|
39
|
+
return "[truncated: hit max_tokens — raise --max-tokens]";
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
export function fromWireResponse(data) {
|
|
44
|
+
const raw = Array.isArray(data.content) ? data.content : [];
|
|
45
|
+
const content = [];
|
|
46
|
+
for (const item of raw) {
|
|
47
|
+
const block = item;
|
|
48
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
49
|
+
content.push({ kind: "text", text: block.text });
|
|
50
|
+
}
|
|
51
|
+
else if (block.type === "tool_use" &&
|
|
52
|
+
typeof block.id === "string" &&
|
|
53
|
+
typeof block.name === "string") {
|
|
54
|
+
content.push({
|
|
55
|
+
kind: "tool_call",
|
|
56
|
+
id: block.id,
|
|
57
|
+
name: block.name,
|
|
58
|
+
input: (block.input ?? {}),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
// thinking / redacted_thinking and anything future: carried in `raw` only.
|
|
62
|
+
}
|
|
63
|
+
const notice = stopNotice(data);
|
|
64
|
+
if (notice !== undefined)
|
|
65
|
+
content.push({ kind: "text", text: notice });
|
|
66
|
+
return { role: "assistant", content, raw, rawFrom: "anthropic", usage: normalizeUsage(data) };
|
|
67
|
+
}
|
|
68
|
+
function normalizeUsage(data) {
|
|
69
|
+
const usage = data.usage;
|
|
70
|
+
if (usage === undefined)
|
|
71
|
+
return undefined;
|
|
72
|
+
return {
|
|
73
|
+
inputTokens: usage.input_tokens ?? 0,
|
|
74
|
+
outputTokens: usage.output_tokens ?? 0,
|
|
75
|
+
cachedInputTokens: usage.cache_read_input_tokens ?? 0,
|
|
76
|
+
cacheWriteInputTokens: usage.cache_creation_input_tokens ?? 0,
|
|
77
|
+
reasoningTokens: 0,
|
|
78
|
+
};
|
|
79
|
+
}
|