mini-coder 0.7.2 → 0.7.4
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/bun.lock +7 -3
- package/package.json +2 -2
- package/src/agent.ts +42 -27
- package/src/args.ts +6 -54
- package/src/index.ts +9 -1
- package/src/models.ts +191 -0
- package/src/oauth.ts +10 -20
- package/src/tui-conversation.ts +12 -0
- package/src/tui-overlay.ts +9 -23
- package/src/tui.ts +6 -0
- package/src/types.ts +6 -0
- package/src/update.ts +171 -0
package/bun.lock
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"dependencies": {
|
|
8
8
|
"@cel-tui/components": "^0.8.3",
|
|
9
9
|
"@cel-tui/core": "^0.8.3",
|
|
10
|
-
"@earendil-works/pi-ai": "^0.
|
|
10
|
+
"@earendil-works/pi-ai": "^0.80.3",
|
|
11
11
|
"diff": "^9.0.0",
|
|
12
12
|
"simple-git": "^3.36.0",
|
|
13
13
|
"yaml": "^2.9.0",
|
|
@@ -101,7 +101,7 @@
|
|
|
101
101
|
|
|
102
102
|
"@cel-tui/types": ["@cel-tui/types@0.8.3", "", { "peerDependencies": { "typescript": ">=5.0.0" } }, "sha512-yEKXCY16U/W4A4JaVysv2/x/IBEHQAfb93CzL6HugN+N15ETB3Vn8tZrQwdyrOT/9WdlCQkeBH8Vj7BJHs4Hkw=="],
|
|
103
103
|
|
|
104
|
-
"@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.
|
|
104
|
+
"@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.80.3", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-jPZLMeGL5kkMSEAwAklfXTMHqZvfhsJtCCpKGIr5Duk7mc0n4skjB1dugk7y0z3z8ZHIUCmPAWHdyDqgUz5vdA=="],
|
|
105
105
|
|
|
106
106
|
"@google/genai": ["@google/genai@1.52.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q=="],
|
|
107
107
|
|
|
@@ -109,10 +109,14 @@
|
|
|
109
109
|
|
|
110
110
|
"@kwsites/promise-deferred": ["@kwsites/promise-deferred@1.1.1", "", {}, "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="],
|
|
111
111
|
|
|
112
|
-
"@mistralai/mistralai": ["@mistralai/mistralai@2.2.
|
|
112
|
+
"@mistralai/mistralai": ["@mistralai/mistralai@2.2.6", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.40.0", "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ=="],
|
|
113
113
|
|
|
114
114
|
"@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="],
|
|
115
115
|
|
|
116
|
+
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
|
117
|
+
|
|
118
|
+
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
|
|
119
|
+
|
|
116
120
|
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
|
117
121
|
|
|
118
122
|
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mini-coder",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"packageManager": "bun@1.3.13",
|
|
6
6
|
"bin": {
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@cel-tui/components": "^0.8.3",
|
|
24
24
|
"@cel-tui/core": "^0.8.3",
|
|
25
|
-
"@earendil-works/pi-ai": "^0.
|
|
25
|
+
"@earendil-works/pi-ai": "^0.80.3",
|
|
26
26
|
"diff": "^9.0.0",
|
|
27
27
|
"simple-git": "^3.36.0",
|
|
28
28
|
"yaml": "^2.9.0"
|
package/src/agent.ts
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
type ToolResultMessage,
|
|
1
|
+
import type {
|
|
2
|
+
AssistantMessage,
|
|
3
|
+
Context,
|
|
4
|
+
ImageContent,
|
|
5
|
+
Message,
|
|
6
|
+
TextContent,
|
|
7
|
+
ToolCall,
|
|
8
|
+
ToolResultMessage,
|
|
10
9
|
} from "@earendil-works/pi-ai";
|
|
11
|
-
import {
|
|
10
|
+
import { createAppModels } from "./models.ts";
|
|
12
11
|
import { estimateTokens } from "./shared";
|
|
13
12
|
import type {
|
|
14
13
|
AgentContex,
|
|
@@ -21,17 +20,31 @@ import type {
|
|
|
21
20
|
// Published research found that **simply hiding old tool outputs** matched the quality of full LLM summarization with **zero extra compute**:
|
|
22
21
|
// https://blog.jetbrains.com/research/2025/12/efficient-context-management/
|
|
23
22
|
export function compactContext(messages: Message[]) {
|
|
24
|
-
// TODO: Preserve SKILL.md contents, and exclude them from compaction.
|
|
25
|
-
// Problem: How do we know? we need to check each message result againt it's arguments
|
|
26
|
-
// and then find if by chance it has a read skill command... This is a mess. We we.
|
|
27
|
-
// A better way would be to first add a read(path, lines, offset). And then we know
|
|
28
|
-
// which files are read using it, and can match by path if it's a SKILL.md ending.
|
|
29
23
|
const KEEP_OBSERVATIONS = 10;
|
|
30
24
|
|
|
25
|
+
const skillReadToolCallIds = new Set<string>();
|
|
26
|
+
for (const message of messages) {
|
|
27
|
+
if (message.role !== "assistant") continue;
|
|
28
|
+
|
|
29
|
+
for (const content of message.content) {
|
|
30
|
+
if (content.type !== "toolCall") continue;
|
|
31
|
+
if (content.name !== "read") continue;
|
|
32
|
+
|
|
33
|
+
const path = content.arguments.path;
|
|
34
|
+
if (typeof path === "string" && path.endsWith("SKILL.md")) {
|
|
35
|
+
skillReadToolCallIds.add(content.id);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
31
40
|
const toolResultIndices: number[] = [];
|
|
32
41
|
for (let i = 0; i < messages.length; i++) {
|
|
33
42
|
if (messages[i].role !== "toolResult") continue;
|
|
34
|
-
|
|
43
|
+
|
|
44
|
+
const message = messages[i] as ToolResultMessage;
|
|
45
|
+
if (skillReadToolCallIds.has(message.toolCallId)) continue;
|
|
46
|
+
|
|
47
|
+
const content = message.content;
|
|
35
48
|
if (
|
|
36
49
|
content.length === 1 &&
|
|
37
50
|
content[0].type === "text" &&
|
|
@@ -73,15 +86,17 @@ export async function* streamAgent(
|
|
|
73
86
|
messages: agentCtx.messages,
|
|
74
87
|
};
|
|
75
88
|
|
|
76
|
-
|
|
77
|
-
const apiKey = await getApiKey(agentCtx.options);
|
|
89
|
+
const models = createAppModels(agentCtx.options.customProviders);
|
|
78
90
|
|
|
79
91
|
// Main agent loop, continues until llm sends a response other than toolCall or has no tool calls.
|
|
80
92
|
while (true) {
|
|
81
|
-
const
|
|
93
|
+
const estimate = estimateTokens(JSON.stringify(llmCtx));
|
|
94
|
+
// 80k is the agreed uppon threshold to the DUMB ZONE
|
|
95
|
+
if (estimate > 80000) compactContext(llmCtx.messages);
|
|
96
|
+
|
|
97
|
+
const s = models.streamSimple(agentCtx.options.model, llmCtx, {
|
|
82
98
|
reasoning: agentCtx.options.effort,
|
|
83
99
|
signal: agentCtx.signal,
|
|
84
|
-
apiKey,
|
|
85
100
|
});
|
|
86
101
|
|
|
87
102
|
let partial: AssistantMessage | null = null;
|
|
@@ -116,6 +131,8 @@ export async function* streamAgent(
|
|
|
116
131
|
|
|
117
132
|
case "error": {
|
|
118
133
|
const finalMessage = await s.result();
|
|
134
|
+
if (!finalMessage) throw new Error(e.error.errorMessage);
|
|
135
|
+
|
|
119
136
|
if (added) {
|
|
120
137
|
llmCtx.messages[llmCtx.messages.length - 1] = finalMessage;
|
|
121
138
|
} else {
|
|
@@ -127,15 +144,11 @@ export async function* streamAgent(
|
|
|
127
144
|
return;
|
|
128
145
|
}
|
|
129
146
|
}
|
|
130
|
-
|
|
131
|
-
// Experiment: use this inside the inner loop for "running" compaction
|
|
132
|
-
// after we are in the dumb zone.
|
|
133
|
-
const estimate = estimateTokens(JSON.stringify(llmCtx));
|
|
134
|
-
// 80k is the agreed uppon threshold to the DUMB ZONE
|
|
135
|
-
if (estimate > 80000) compactContext(llmCtx.messages);
|
|
136
147
|
}
|
|
137
148
|
|
|
138
149
|
const message = await s.result();
|
|
150
|
+
if (!message) throw new Error("Stream finished without a message");
|
|
151
|
+
|
|
139
152
|
if (added) {
|
|
140
153
|
llmCtx.messages[llmCtx.messages.length - 1] = message;
|
|
141
154
|
} else {
|
|
@@ -144,7 +157,9 @@ export async function* streamAgent(
|
|
|
144
157
|
}
|
|
145
158
|
yield { type: "message_end", message };
|
|
146
159
|
|
|
147
|
-
const toolCalls = message.content.filter(
|
|
160
|
+
const toolCalls = message.content.filter(
|
|
161
|
+
(c): c is ToolCall => c.type === "toolCall",
|
|
162
|
+
);
|
|
148
163
|
|
|
149
164
|
// Stop on errors or no tools to call.
|
|
150
165
|
if (message.stopReason !== "toolUse" || toolCalls.length === 0) {
|
package/src/args.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { mkdir } from "node:fs/promises";
|
|
2
|
-
import {
|
|
3
|
-
type Api,
|
|
4
|
-
getModels,
|
|
5
|
-
getProviders,
|
|
6
|
-
type Model,
|
|
7
|
-
type ThinkingLevel,
|
|
8
|
-
} from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ThinkingLevel } from "@earendil-works/pi-ai";
|
|
9
3
|
import { Value } from "typebox/value";
|
|
4
|
+
import {
|
|
5
|
+
findModelConfig,
|
|
6
|
+
getFallbackModel,
|
|
7
|
+
getFirstModelConfig,
|
|
8
|
+
} from "./models.ts";
|
|
10
9
|
import { getAvailableProviders, isOAuthProvider, loginOAuth } from "./oauth";
|
|
11
10
|
import { DATA_DIR, SETTINGS_PATH } from "./shared.ts";
|
|
12
11
|
import { DEFAULT_TUI_THEME_ID } from "./themes.ts";
|
|
@@ -92,53 +91,6 @@ function requireValue(argv: string[], index: number, flag: string) {
|
|
|
92
91
|
return value;
|
|
93
92
|
}
|
|
94
93
|
|
|
95
|
-
function findModelConfig(
|
|
96
|
-
modelId: string,
|
|
97
|
-
provider: string,
|
|
98
|
-
customProviders?: Model<Api>[],
|
|
99
|
-
) {
|
|
100
|
-
const knownProviders = getProviders() as string[];
|
|
101
|
-
if (knownProviders.includes(provider)) {
|
|
102
|
-
const models = getModels(provider as any);
|
|
103
|
-
if (models.length === 0) throw new Error("Provider has no models");
|
|
104
|
-
return models.find((m) => m.id === modelId);
|
|
105
|
-
}
|
|
106
|
-
return customProviders?.find(
|
|
107
|
-
(m) => m.provider === provider && m.id === modelId,
|
|
108
|
-
);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function getFirstModelConfig(
|
|
112
|
-
provider: string,
|
|
113
|
-
customProviders?: Model<Api>[],
|
|
114
|
-
): Model<Api> {
|
|
115
|
-
const knownProviders = getProviders() as string[];
|
|
116
|
-
if (knownProviders.includes(provider)) {
|
|
117
|
-
const models = getModels(provider as any);
|
|
118
|
-
if (models.length > 0) return models[0];
|
|
119
|
-
}
|
|
120
|
-
const custom = customProviders?.find((m) => m.provider === provider);
|
|
121
|
-
if (custom) return custom;
|
|
122
|
-
throw new Error("Provider has no models");
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function getFallbackModel(
|
|
126
|
-
provider: string,
|
|
127
|
-
explicitModel: boolean,
|
|
128
|
-
providerChanged: boolean,
|
|
129
|
-
customProviders?: Model<Api>[],
|
|
130
|
-
): Model<Api> {
|
|
131
|
-
if (explicitModel) {
|
|
132
|
-
throw new Error("Model not found");
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
if (!providerChanged) {
|
|
136
|
-
throw new Error("Model not found");
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
return getFirstModelConfig(provider, customProviders);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
94
|
export async function handleArgv(argv: string[]): Promise<CliOptions> {
|
|
143
95
|
const settings = await getSettings();
|
|
144
96
|
const customProviders = settings?.customProviders ?? [];
|
package/src/index.ts
CHANGED
|
@@ -4,10 +4,18 @@ import { getBranchLabel } from "./git.ts";
|
|
|
4
4
|
import { streamHeadless } from "./headless.ts";
|
|
5
5
|
import { initTUI } from "./tui.ts";
|
|
6
6
|
import type { TUIState } from "./types.ts";
|
|
7
|
+
import { updateMiniCoder } from "./update.ts";
|
|
7
8
|
|
|
8
9
|
export async function main(): Promise<void> {
|
|
10
|
+
const argv = process.argv.slice(2);
|
|
11
|
+
|
|
12
|
+
if (argv.includes("--update")) {
|
|
13
|
+
await updateMiniCoder();
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
9
17
|
const cwd = basename(process.cwd());
|
|
10
|
-
const options = await handleArgv(
|
|
18
|
+
const options = await handleArgv(argv);
|
|
11
19
|
|
|
12
20
|
function leave(msg?: string) {
|
|
13
21
|
if (msg) console.log(msg);
|
package/src/models.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Api,
|
|
3
|
+
Credential,
|
|
4
|
+
CredentialStore,
|
|
5
|
+
KnownApi,
|
|
6
|
+
KnownProvider,
|
|
7
|
+
Model,
|
|
8
|
+
ProviderStreams,
|
|
9
|
+
} from "@earendil-works/pi-ai";
|
|
10
|
+
import { createProvider } from "@earendil-works/pi-ai";
|
|
11
|
+
import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
|
|
12
|
+
import { azureOpenAIResponsesApi } from "@earendil-works/pi-ai/api/azure-openai-responses.lazy";
|
|
13
|
+
import { bedrockConverseStreamApi } from "@earendil-works/pi-ai/api/bedrock-converse-stream.lazy";
|
|
14
|
+
import { googleGenerativeAIApi } from "@earendil-works/pi-ai/api/google-generative-ai.lazy";
|
|
15
|
+
import { googleVertexApi } from "@earendil-works/pi-ai/api/google-vertex.lazy";
|
|
16
|
+
import { mistralConversationsApi } from "@earendil-works/pi-ai/api/mistral-conversations.lazy";
|
|
17
|
+
import { openAICodexResponsesApi } from "@earendil-works/pi-ai/api/openai-codex-responses.lazy";
|
|
18
|
+
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
19
|
+
import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy";
|
|
20
|
+
import {
|
|
21
|
+
builtinModels,
|
|
22
|
+
getBuiltinModels,
|
|
23
|
+
getBuiltinProviders,
|
|
24
|
+
} from "@earendil-works/pi-ai/providers/all";
|
|
25
|
+
import { AUTH_PATH } from "./shared.ts";
|
|
26
|
+
|
|
27
|
+
const API_STREAMS: Record<KnownApi, () => ProviderStreams> = {
|
|
28
|
+
"anthropic-messages": anthropicMessagesApi,
|
|
29
|
+
"azure-openai-responses": azureOpenAIResponsesApi,
|
|
30
|
+
"bedrock-converse-stream": bedrockConverseStreamApi,
|
|
31
|
+
"google-generative-ai": googleGenerativeAIApi,
|
|
32
|
+
"google-vertex": googleVertexApi,
|
|
33
|
+
"mistral-conversations": mistralConversationsApi,
|
|
34
|
+
"openai-codex-responses": openAICodexResponsesApi,
|
|
35
|
+
"openai-completions": openAICompletionsApi,
|
|
36
|
+
"openai-responses": openAIResponsesApi,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type SavedCredentials = Record<string, Credential>;
|
|
40
|
+
|
|
41
|
+
async function readCredentials(): Promise<SavedCredentials> {
|
|
42
|
+
const file = Bun.file(AUTH_PATH);
|
|
43
|
+
if (await file.exists()) {
|
|
44
|
+
return JSON.parse(await file.text()) as SavedCredentials;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function writeCredentials(credentials: SavedCredentials) {
|
|
51
|
+
await Bun.write(AUTH_PATH, JSON.stringify(credentials));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function createCredentialStore(): CredentialStore {
|
|
55
|
+
return {
|
|
56
|
+
async read(providerId) {
|
|
57
|
+
const credentials = await readCredentials();
|
|
58
|
+
return credentials[providerId];
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
async modify(providerId, fn) {
|
|
62
|
+
const credentials = await readCredentials();
|
|
63
|
+
const next = await fn(credentials[providerId]);
|
|
64
|
+
|
|
65
|
+
if (next) {
|
|
66
|
+
credentials[providerId] = next;
|
|
67
|
+
await writeCredentials(credentials);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return credentials[providerId];
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
async delete(providerId) {
|
|
74
|
+
const credentials = await readCredentials();
|
|
75
|
+
delete credentials[providerId];
|
|
76
|
+
await writeCredentials(credentials);
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function isBuiltinProvider(provider: string): provider is KnownProvider {
|
|
82
|
+
return getBuiltinProviders().includes(provider as KnownProvider);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function findModelConfig(
|
|
86
|
+
modelId: string,
|
|
87
|
+
provider: string,
|
|
88
|
+
customProviders?: Model<Api>[],
|
|
89
|
+
) {
|
|
90
|
+
if (isBuiltinProvider(provider)) {
|
|
91
|
+
const models = getBuiltinModels(provider);
|
|
92
|
+
if (models.length === 0) throw new Error("Provider has no models");
|
|
93
|
+
return models.find((m) => m.id === modelId);
|
|
94
|
+
}
|
|
95
|
+
return customProviders?.find(
|
|
96
|
+
(m) => m.provider === provider && m.id === modelId,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function getFirstModelConfig(
|
|
101
|
+
provider: string,
|
|
102
|
+
customProviders?: Model<Api>[],
|
|
103
|
+
): Model<Api> {
|
|
104
|
+
if (isBuiltinProvider(provider)) {
|
|
105
|
+
const models = getBuiltinModels(provider);
|
|
106
|
+
if (models.length > 0) return models[0];
|
|
107
|
+
}
|
|
108
|
+
const custom = customProviders?.find((m) => m.provider === provider);
|
|
109
|
+
if (custom) return custom;
|
|
110
|
+
throw new Error("Provider has no models");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function getFallbackModel(
|
|
114
|
+
provider: string,
|
|
115
|
+
explicitModel: boolean,
|
|
116
|
+
providerChanged: boolean,
|
|
117
|
+
customProviders?: Model<Api>[],
|
|
118
|
+
): Model<Api> {
|
|
119
|
+
if (explicitModel) {
|
|
120
|
+
throw new Error("Model not found");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!providerChanged) {
|
|
124
|
+
throw new Error("Model not found");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return getFirstModelConfig(provider, customProviders);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function getProviderModels(
|
|
131
|
+
provider: string,
|
|
132
|
+
customProviders?: Model<Api>[],
|
|
133
|
+
): Model<Api>[] {
|
|
134
|
+
const builtIn = isBuiltinProvider(provider) ? getBuiltinModels(provider) : [];
|
|
135
|
+
const custom = customProviders?.filter((m) => m.provider === provider) ?? [];
|
|
136
|
+
|
|
137
|
+
return [...builtIn, ...custom];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function createAppModels(customProviders?: Model<Api>[]) {
|
|
141
|
+
const models = builtinModels({ credentials: createCredentialStore() });
|
|
142
|
+
const customProviderGroups = new Map<string, Model<Api>[]>();
|
|
143
|
+
|
|
144
|
+
for (const model of customProviders ?? []) {
|
|
145
|
+
const group = customProviderGroups.get(model.provider) ?? [];
|
|
146
|
+
group.push(model);
|
|
147
|
+
customProviderGroups.set(model.provider, group);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const [provider, providerModels] of customProviderGroups) {
|
|
151
|
+
models.setProvider(
|
|
152
|
+
createProvider({
|
|
153
|
+
id: provider,
|
|
154
|
+
name: provider,
|
|
155
|
+
models: providerModels,
|
|
156
|
+
auth: {
|
|
157
|
+
apiKey: {
|
|
158
|
+
name: provider,
|
|
159
|
+
resolve: async () => ({ auth: { apiKey: "dummy" } }),
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
api: Object.fromEntries(
|
|
163
|
+
Object.entries(API_STREAMS)
|
|
164
|
+
.filter(([api]) =>
|
|
165
|
+
providerModels.some((model) => model.api === api),
|
|
166
|
+
)
|
|
167
|
+
.map(([api, createStreams]) => [api, createStreams()]),
|
|
168
|
+
),
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return models;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function getConfiguredBuiltinProviders(): Promise<string[]> {
|
|
177
|
+
const models = builtinModels({ credentials: createCredentialStore() });
|
|
178
|
+
const providers: string[] = [];
|
|
179
|
+
|
|
180
|
+
for (const provider of models.getProviders()) {
|
|
181
|
+
const model = provider.getModels()[0];
|
|
182
|
+
if (!model) continue;
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const auth = await models.getAuth(model);
|
|
186
|
+
if (auth) providers.push(provider.id);
|
|
187
|
+
} catch {}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return providers;
|
|
191
|
+
}
|
package/src/oauth.ts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import { mkdir } from "node:fs/promises";
|
|
2
2
|
import readline from "node:readline";
|
|
3
3
|
|
|
4
|
-
import { getEnvApiKey, getProviders } from "@earendil-works/pi-ai";
|
|
5
4
|
import {
|
|
6
|
-
getOAuthApiKey,
|
|
7
5
|
getOAuthProvider,
|
|
8
6
|
getOAuthProviders,
|
|
9
7
|
type OAuthLoginCallbacks,
|
|
@@ -11,6 +9,11 @@ import {
|
|
|
11
9
|
type OAuthProviderId,
|
|
12
10
|
type OAuthSelectPrompt,
|
|
13
11
|
} from "@earendil-works/pi-ai/oauth";
|
|
12
|
+
import {
|
|
13
|
+
createAppModels,
|
|
14
|
+
getConfiguredBuiltinProviders,
|
|
15
|
+
isBuiltinProvider,
|
|
16
|
+
} from "./models.ts";
|
|
14
17
|
import { AUTH_PATH as AUTH_FILE, DATA_DIR } from "./shared";
|
|
15
18
|
import type { CliOptions, SavedOAuthCreds } from "./types";
|
|
16
19
|
|
|
@@ -66,9 +69,7 @@ export async function getAvailableProviders(): Promise<string[]> {
|
|
|
66
69
|
const loggedInOAuthProviders = getOAuthProviders()
|
|
67
70
|
.map((provider) => provider.id)
|
|
68
71
|
.filter((provider) => auth[provider]);
|
|
69
|
-
const envKeyProviders =
|
|
70
|
-
(provider) => !!getEnvApiKey(provider),
|
|
71
|
-
);
|
|
72
|
+
const envKeyProviders = await getConfiguredBuiltinProviders();
|
|
72
73
|
const providers: string[] = [];
|
|
73
74
|
const providerIds = new Set<string>();
|
|
74
75
|
|
|
@@ -104,23 +105,12 @@ export async function loginOAuth(provider: OAuthProviderId) {
|
|
|
104
105
|
|
|
105
106
|
export async function getApiKey(options: CliOptions) {
|
|
106
107
|
const provider = options.model.provider;
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
if (isOAuthProvider(provider)) {
|
|
110
|
-
const result = await getOAuthApiKey(provider, auth);
|
|
111
|
-
if (result) {
|
|
112
|
-
auth[provider] = { type: "oauth", ...result.newCredentials };
|
|
113
|
-
await writeCreds(auth);
|
|
114
|
-
|
|
115
|
-
return result.apiKey;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
108
|
+
const models = createAppModels(options.customProviders);
|
|
109
|
+
const auth = await models.getAuth(options.model);
|
|
118
110
|
|
|
119
|
-
|
|
120
|
-
if (envApiKey) return envApiKey;
|
|
111
|
+
if (auth?.auth.apiKey) return auth.auth.apiKey;
|
|
121
112
|
|
|
122
|
-
|
|
123
|
-
if (!knownProviders.includes(provider)) {
|
|
113
|
+
if (!isBuiltinProvider(provider)) {
|
|
124
114
|
if (options.model.api === "openai-completions") {
|
|
125
115
|
// pi-ai requires a truthy apiKey for OpenAI-compatible local providers like Ollama.
|
|
126
116
|
return "dummy";
|
package/src/tui-conversation.ts
CHANGED
|
@@ -14,6 +14,17 @@ export function emptyState(state: TUIState): Node {
|
|
|
14
14
|
state.options.theme === "ansi16"
|
|
15
15
|
? randColor
|
|
16
16
|
: textColorForBackground(theme.bblack, state.options.theme);
|
|
17
|
+
const updateNotice = state.availableUpdate
|
|
18
|
+
? [
|
|
19
|
+
HStack({ gap: 1 }, [
|
|
20
|
+
TextPill("update", shortcutFgColor, theme.bblack, 13),
|
|
21
|
+
Text(
|
|
22
|
+
`mini-coder ${state.availableUpdate.latestVersion} is available. Run mc --update.`,
|
|
23
|
+
{ fgColor: theme.bblack },
|
|
24
|
+
),
|
|
25
|
+
]),
|
|
26
|
+
]
|
|
27
|
+
: [];
|
|
17
28
|
return HStack({ flex: 1, alignItems: "center" }, [
|
|
18
29
|
VStack({ flex: 1, alignItems: "center", gap: 1 }, [
|
|
19
30
|
HStack({ gap: 1 }, [
|
|
@@ -45,6 +56,7 @@ export function emptyState(state: TUIState): Node {
|
|
|
45
56
|
TextPill("ctrl+c|d|q", shortcutFgColor, theme.bblack, 13),
|
|
46
57
|
Text("Quit.", { fgColor: theme.bblack }),
|
|
47
58
|
]),
|
|
59
|
+
...updateNotice,
|
|
48
60
|
]),
|
|
49
61
|
]),
|
|
50
62
|
]);
|
package/src/tui-overlay.ts
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { cel, HStack, Text, TextInput, VStack } from "@cel-tui/core";
|
|
2
|
-
import {
|
|
3
|
-
getModels,
|
|
4
|
-
type Message,
|
|
5
|
-
type ThinkingLevel,
|
|
6
|
-
} from "@earendil-works/pi-ai";
|
|
2
|
+
import type { Message, ThinkingLevel } from "@earendil-works/pi-ai";
|
|
7
3
|
import { getOAuthProviders } from "@earendil-works/pi-ai/oauth";
|
|
8
4
|
import { saveSettings } from "./args";
|
|
5
|
+
import { findModelConfig, getProviderModels } from "./models.ts";
|
|
9
6
|
import { getAvailableProviders } from "./oauth";
|
|
10
7
|
import { listSessionsForCwd } from "./session";
|
|
11
8
|
import { estimateTokens, formatTimestamp } from "./shared";
|
|
@@ -291,20 +288,11 @@ export function mainMenu(state: TUIState, initialPane = "main") {
|
|
|
291
288
|
"xhigh",
|
|
292
289
|
];
|
|
293
290
|
const efforts = reasoningEfforts.map((v) => ({ label: v, value: v }));
|
|
294
|
-
const
|
|
295
|
-
|
|
291
|
+
const getProviderModelOptions = (provider: string) =>
|
|
292
|
+
getProviderModels(provider, state.options.customProviders).map((v) => ({
|
|
296
293
|
label: v.name,
|
|
297
294
|
value: v.id,
|
|
298
295
|
}));
|
|
299
|
-
const custom =
|
|
300
|
-
state.options.customProviders
|
|
301
|
-
?.filter((m) => m.provider === provider)
|
|
302
|
-
.map((v) => ({
|
|
303
|
-
label: v.name,
|
|
304
|
-
value: v.id,
|
|
305
|
-
})) ?? [];
|
|
306
|
-
return [...builtIn, ...custom];
|
|
307
|
-
};
|
|
308
296
|
|
|
309
297
|
let currentProviders: string[] = [];
|
|
310
298
|
let currentSessions: Session[] = [];
|
|
@@ -560,7 +548,7 @@ export function mainMenu(state: TUIState, initialPane = "main") {
|
|
|
560
548
|
openPane(s, {
|
|
561
549
|
label: "models",
|
|
562
550
|
filter: "",
|
|
563
|
-
list:
|
|
551
|
+
list: getProviderModelOptions(selectedProvider),
|
|
564
552
|
});
|
|
565
553
|
return;
|
|
566
554
|
}
|
|
@@ -568,13 +556,11 @@ export function mainMenu(state: TUIState, initialPane = "main") {
|
|
|
568
556
|
if (currentPane.label === "models") {
|
|
569
557
|
if (!selectedProvider) return;
|
|
570
558
|
|
|
571
|
-
const
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
(v) => v.provider === selectedProvider && v.id === s.selected,
|
|
559
|
+
const model = findModelConfig(
|
|
560
|
+
s.selected,
|
|
561
|
+
selectedProvider,
|
|
562
|
+
state.options.customProviders,
|
|
576
563
|
);
|
|
577
|
-
const model = builtIn ?? custom;
|
|
578
564
|
if (!model) return;
|
|
579
565
|
|
|
580
566
|
state.options.provider = selectedProvider;
|
package/src/tui.ts
CHANGED
|
@@ -30,6 +30,11 @@ import { Conversation, emptyState } from "./tui-conversation";
|
|
|
30
30
|
import { Editor } from "./tui-editor";
|
|
31
31
|
import { mainMenu } from "./tui-overlay";
|
|
32
32
|
import type { AgentContex, ToolAndRunner, TUIMessage, TUIState } from "./types";
|
|
33
|
+
import { getAvailableUpdate } from "./update";
|
|
34
|
+
|
|
35
|
+
async function refreshAvailableUpdate(state: TUIState): Promise<void> {
|
|
36
|
+
state.availableUpdate = await getAvailableUpdate();
|
|
37
|
+
}
|
|
33
38
|
|
|
34
39
|
function clearOrAbort(state: TUIState) {
|
|
35
40
|
// Are we mid stream? Abort it.
|
|
@@ -46,6 +51,7 @@ function clearOrAbort(state: TUIState) {
|
|
|
46
51
|
export function initTUI(state: TUIState, leave: (s: string) => void) {
|
|
47
52
|
// TODO: Cleanup accumulated sessions for this cwd.
|
|
48
53
|
const { spinnerEvery, currentSpinner } = Spinner();
|
|
54
|
+
void refreshAvailableUpdate(state);
|
|
49
55
|
|
|
50
56
|
// Stable 60fps rendering.
|
|
51
57
|
// This ensure Xfps, and excessive calls get coalesced in cel-tui.
|
package/src/types.ts
CHANGED
|
@@ -98,6 +98,11 @@ export type TUIMessage = {
|
|
|
98
98
|
toolCalls?: TUIToolCall[];
|
|
99
99
|
};
|
|
100
100
|
|
|
101
|
+
export type AvailableUpdate = {
|
|
102
|
+
currentVersion: string;
|
|
103
|
+
latestVersion: string;
|
|
104
|
+
};
|
|
105
|
+
|
|
101
106
|
export type TUIState = {
|
|
102
107
|
options: CliOptions;
|
|
103
108
|
prompt: string;
|
|
@@ -110,6 +115,7 @@ export type TUIState = {
|
|
|
110
115
|
abortController?: AbortController;
|
|
111
116
|
cwd: string;
|
|
112
117
|
gitBranch?: string;
|
|
118
|
+
availableUpdate?: AvailableUpdate | undefined;
|
|
113
119
|
overlay?: boolean | undefined;
|
|
114
120
|
forceThemeRefresh?: boolean | undefined;
|
|
115
121
|
sessionId?: string | undefined;
|
package/src/update.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { type Static, Type } from "@earendil-works/pi-ai";
|
|
5
|
+
import { Value } from "typebox/value";
|
|
6
|
+
|
|
7
|
+
import { DATA_DIR } from "./shared";
|
|
8
|
+
import type { AvailableUpdate } from "./types";
|
|
9
|
+
|
|
10
|
+
const PACKAGE_NAME = "mini-coder";
|
|
11
|
+
const PACKAGE_MANIFEST_URL = new URL("../package.json", import.meta.url);
|
|
12
|
+
const UPDATE_CHECK_CACHE_PATH = join(DATA_DIR, "update-check.json");
|
|
13
|
+
const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
14
|
+
const UPDATE_CHECK_TIMEOUT_MS = 5_000;
|
|
15
|
+
|
|
16
|
+
const UpdateCheckCacheSchema = Type.Object({
|
|
17
|
+
checkedAt: Type.Number(),
|
|
18
|
+
currentVersion: Type.String(),
|
|
19
|
+
latestVersion: Type.Optional(Type.String()),
|
|
20
|
+
});
|
|
21
|
+
type UpdateCheckCache = Static<typeof UpdateCheckCacheSchema>;
|
|
22
|
+
|
|
23
|
+
function parseLatestVersion(output: string): string | undefined {
|
|
24
|
+
const version = output.trim().split(/\s+/)[0];
|
|
25
|
+
|
|
26
|
+
if (!version || !isValidVersion(version)) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return version;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isValidVersion(version: string): boolean {
|
|
34
|
+
try {
|
|
35
|
+
Bun.semver.order(version, version);
|
|
36
|
+
return true;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isNewerVersion(
|
|
43
|
+
currentVersion: string,
|
|
44
|
+
latestVersion: string,
|
|
45
|
+
): boolean {
|
|
46
|
+
try {
|
|
47
|
+
return Bun.semver.order(latestVersion, currentVersion) === 1;
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isFreshUpdateCheck(cache: UpdateCheckCache, now: number): boolean {
|
|
54
|
+
return now - cache.checkedAt < UPDATE_CHECK_INTERVAL_MS;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getAvailableUpdateFromLatest(
|
|
58
|
+
currentVersion: string,
|
|
59
|
+
latestVersion: string | undefined,
|
|
60
|
+
): AvailableUpdate | undefined {
|
|
61
|
+
if (!latestVersion || !isNewerVersion(currentVersion, latestVersion)) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { currentVersion, latestVersion };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function getCurrentVersion(): Promise<string | undefined> {
|
|
69
|
+
const manifest = (await Bun.file(PACKAGE_MANIFEST_URL).json()) as {
|
|
70
|
+
version?: unknown;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
if (typeof manifest.version !== "string") {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return manifest.version;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function getLatestVersion(): Promise<string | undefined> {
|
|
81
|
+
try {
|
|
82
|
+
const proc = Bun.spawn(["bun", "pm", "view", PACKAGE_NAME, "version"], {
|
|
83
|
+
stdout: "pipe",
|
|
84
|
+
stderr: "ignore",
|
|
85
|
+
timeout: UPDATE_CHECK_TIMEOUT_MS,
|
|
86
|
+
});
|
|
87
|
+
const [stdout, exitCode] = await Promise.all([
|
|
88
|
+
proc.stdout.text(),
|
|
89
|
+
proc.exited,
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
if (exitCode !== 0) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return parseLatestVersion(stdout);
|
|
97
|
+
} catch {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function readUpdateCheckCache(): Promise<UpdateCheckCache | undefined> {
|
|
103
|
+
try {
|
|
104
|
+
const file = Bun.file(UPDATE_CHECK_CACHE_PATH);
|
|
105
|
+
|
|
106
|
+
if (!(await file.exists())) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const value = (await file.json()) as unknown;
|
|
111
|
+
|
|
112
|
+
if (!Value.Check(UpdateCheckCacheSchema, value)) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return value;
|
|
117
|
+
} catch {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function writeUpdateCheckCache(cache: UpdateCheckCache): Promise<void> {
|
|
123
|
+
try {
|
|
124
|
+
await mkdir(DATA_DIR, { recursive: true });
|
|
125
|
+
await Bun.write(UPDATE_CHECK_CACHE_PATH, JSON.stringify(cache, null, 2));
|
|
126
|
+
} catch {
|
|
127
|
+
// Update checks are best-effort and must never interrupt startup.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function getAvailableUpdate(): Promise<
|
|
132
|
+
AvailableUpdate | undefined
|
|
133
|
+
> {
|
|
134
|
+
const currentVersion = await getCurrentVersion().catch(() => undefined);
|
|
135
|
+
|
|
136
|
+
if (!currentVersion) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const now = Date.now();
|
|
141
|
+
const cache = await readUpdateCheckCache();
|
|
142
|
+
|
|
143
|
+
if (cache && isFreshUpdateCheck(cache, now)) {
|
|
144
|
+
return getAvailableUpdateFromLatest(currentVersion, cache.latestVersion);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const latestVersion = await getLatestVersion();
|
|
148
|
+
await writeUpdateCheckCache({
|
|
149
|
+
checkedAt: now,
|
|
150
|
+
currentVersion,
|
|
151
|
+
latestVersion,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
return getAvailableUpdateFromLatest(currentVersion, latestVersion);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function updateMiniCoder(): Promise<void> {
|
|
158
|
+
console.log("Updating mini-coder...");
|
|
159
|
+
|
|
160
|
+
const proc = Bun.spawn(["bun", "add", "-g", "mini-coder@latest"], {
|
|
161
|
+
stdout: "inherit",
|
|
162
|
+
stderr: "inherit",
|
|
163
|
+
});
|
|
164
|
+
const exitCode = await proc.exited;
|
|
165
|
+
|
|
166
|
+
if (exitCode !== 0) {
|
|
167
|
+
throw new Error(`Update failed with exit code ${exitCode}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
console.log("mini-coder updated.");
|
|
171
|
+
}
|