nou-cli 0.0.0-stage → 0.1.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.
- package/.env.example +24 -0
- package/README.md +54 -2
- package/dist/index.js +35 -0
- package/dist/src/agent/index.js +214 -0
- package/dist/src/commands/index.js +115 -0
- package/dist/src/config/index.js +36 -0
- package/dist/src/context/index.js +53 -0
- package/dist/src/git/index.js +94 -0
- package/dist/src/git/tools.js +52 -0
- package/dist/src/plugins/index.js +43 -0
- package/dist/src/providers/anthropic.js +109 -0
- package/dist/src/providers/index.js +46 -0
- package/dist/src/providers/ollama.js +57 -0
- package/dist/src/providers/openai.js +106 -0
- package/dist/src/providers/types.js +2 -0
- package/dist/src/tools/index.js +100 -0
- package/dist/src/ui/App.js +432 -0
- package/dist/src/ui/Chat.js +90 -0
- package/dist/src/ui/CodeView.js +84 -0
- package/dist/src/ui/Editor.js +165 -0
- package/dist/src/ui/FileTree.js +141 -0
- package/dist/src/ui/Header.js +9 -0
- package/dist/src/ui/Pane.js +8 -0
- package/dist/src/ui/QuickOpen.js +91 -0
- package/dist/src/ui/Settings.js +150 -0
- package/dist/src/ui/text.js +95 -0
- package/dist/src/ui/theme.js +41 -0
- package/dist/src/util/index.js +9 -0
- package/package.json +64 -4
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { branch, diffText, isRepo, log, status } from "./index.js";
|
|
2
|
+
/** Read-only git tools for the model. */
|
|
3
|
+
export function gitTools() {
|
|
4
|
+
if (!isRepo())
|
|
5
|
+
return [];
|
|
6
|
+
return [
|
|
7
|
+
{
|
|
8
|
+
source: "git",
|
|
9
|
+
readOnly: true,
|
|
10
|
+
definition: {
|
|
11
|
+
name: "git_status",
|
|
12
|
+
description: "Show the current git branch and uncommitted changes (M modified, A added, D deleted, ? untracked).",
|
|
13
|
+
parameters: { type: "object", properties: {}, required: [] },
|
|
14
|
+
},
|
|
15
|
+
run: () => {
|
|
16
|
+
const changes = [...status()].map(([file, code]) => `${code} ${file}`);
|
|
17
|
+
return `Branch: ${branch()}\n${changes.join("\n") || "Working tree clean."}`;
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
source: "git",
|
|
22
|
+
readOnly: true,
|
|
23
|
+
definition: {
|
|
24
|
+
name: "git_diff",
|
|
25
|
+
description: "Show uncommitted changes as a unified diff against HEAD, for one file or the whole repo.",
|
|
26
|
+
parameters: {
|
|
27
|
+
type: "object",
|
|
28
|
+
properties: {
|
|
29
|
+
path: { type: "string", description: "Optional file to diff. Omit for all changes." },
|
|
30
|
+
staged: { type: "boolean", description: "Only show staged changes." },
|
|
31
|
+
},
|
|
32
|
+
required: [],
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
run: (args) => diffText(typeof args.path === "string" ? args.path : undefined, args.staged === true),
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
source: "git",
|
|
39
|
+
readOnly: true,
|
|
40
|
+
definition: {
|
|
41
|
+
name: "git_log",
|
|
42
|
+
description: "Show recent commits (hash, date, author, message).",
|
|
43
|
+
parameters: {
|
|
44
|
+
type: "object",
|
|
45
|
+
properties: { count: { type: "number", description: "How many commits, default 10, max 50." } },
|
|
46
|
+
required: [],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
run: (args) => log(typeof args.count === "number" ? args.count : 10),
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { pathToFileURL } from "url";
|
|
4
|
+
import { NOU_HOME } from "../config/index.js";
|
|
5
|
+
export const PLUGIN_DIRS = [
|
|
6
|
+
{ scope: "project", dir: path.join(process.cwd(), ".nou", "plugins") },
|
|
7
|
+
{ scope: "user", dir: path.join(NOU_HOME, "plugins") },
|
|
8
|
+
];
|
|
9
|
+
const EXTENSIONS = [".js", ".mjs", ".ts", ".mts"];
|
|
10
|
+
/** Finds plugin files without running them. */
|
|
11
|
+
export function discoverPlugins() {
|
|
12
|
+
const found = [];
|
|
13
|
+
for (const { scope, dir } of PLUGIN_DIRS) {
|
|
14
|
+
if (!existsSync(dir))
|
|
15
|
+
continue;
|
|
16
|
+
for (const name of readdirSync(dir)) {
|
|
17
|
+
const full = path.join(dir, name);
|
|
18
|
+
let file;
|
|
19
|
+
if (statSync(full).isDirectory()) {
|
|
20
|
+
file = EXTENSIONS.map((ext) => path.join(full, `index${ext}`)).find(existsSync);
|
|
21
|
+
}
|
|
22
|
+
else if (EXTENSIONS.includes(path.extname(name))) {
|
|
23
|
+
file = full;
|
|
24
|
+
}
|
|
25
|
+
const id = path.basename(name, path.extname(name));
|
|
26
|
+
if (file && !found.some((p) => p.id === id))
|
|
27
|
+
found.push({ id, file, scope });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return found;
|
|
31
|
+
}
|
|
32
|
+
export async function loadPlugin(entry) {
|
|
33
|
+
try {
|
|
34
|
+
const mod = await import(pathToFileURL(entry.file).href);
|
|
35
|
+
const plugin = mod.default ?? mod.plugin;
|
|
36
|
+
if (!plugin || typeof plugin.name !== "string")
|
|
37
|
+
throw new Error("must default-export an object with a name");
|
|
38
|
+
return { ...entry, plugin };
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
return { ...entry, error: error.message };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
// Models that accept server-side refusal fallbacks in their "default" form.
|
|
6
|
+
const FALLBACK_MODELS = /^claude-(fable-5-1|opus-5-5|opus-5|sonnet-5-5)$/;
|
|
7
|
+
// Current-generation models take output_config.effort.
|
|
8
|
+
const EFFORT_MODELS = /^claude-(fable|mythos|opus|sonnet|haiku)-5/;
|
|
9
|
+
const MAX_JSON_RETRIES = 2;
|
|
10
|
+
function toAnthropic(messages) {
|
|
11
|
+
const out = [];
|
|
12
|
+
for (const m of messages) {
|
|
13
|
+
if (m.role === "user") {
|
|
14
|
+
out.push({ role: "user", content: m.content });
|
|
15
|
+
}
|
|
16
|
+
else if (m.role === "assistant") {
|
|
17
|
+
// Replay Claude's own turn unchanged (keeps thinking blocks valid); rebuild turns from other providers.
|
|
18
|
+
const content = m.raw?.provider === "anthropic"
|
|
19
|
+
? m.raw.data
|
|
20
|
+
: [
|
|
21
|
+
...(m.content ? [{ type: "text", text: m.content }] : []),
|
|
22
|
+
...m.toolCalls.map((c) => ({ type: "tool_use", id: c.id, name: c.name, input: c.args })),
|
|
23
|
+
];
|
|
24
|
+
out.push({ role: "assistant", content });
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
const result = { type: "tool_result", tool_use_id: m.callId, content: m.content, ...(m.isError ? { is_error: true } : {}) };
|
|
28
|
+
// All results for one turn go back in a single user message.
|
|
29
|
+
const last = out[out.length - 1];
|
|
30
|
+
if (last?.role === "user" && Array.isArray(last.content) && last.content.every((b) => b.type === "tool_result")) {
|
|
31
|
+
last.content.push(result);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
out.push({ role: "user", content: [result] });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
function hasCredentials() {
|
|
41
|
+
return !!(process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || process.env.ANTHROPIC_PROFILE)
|
|
42
|
+
|| existsSync(path.join(os.homedir(), ".config", "anthropic"));
|
|
43
|
+
}
|
|
44
|
+
export function anthropicProvider() {
|
|
45
|
+
let client = null;
|
|
46
|
+
const getClient = () => (client ??= new Anthropic());
|
|
47
|
+
return {
|
|
48
|
+
id: "anthropic",
|
|
49
|
+
label: "Anthropic (Claude)",
|
|
50
|
+
local: false,
|
|
51
|
+
defaultModel: "claude-opus-5-5",
|
|
52
|
+
unavailable: () => (hasCredentials() ? null : "set ANTHROPIC_API_KEY (or run `ant auth login`)"),
|
|
53
|
+
async chat({ model, system, messages, tools, onText, signal, effort: requested }) {
|
|
54
|
+
const fallbacks = FALLBACK_MODELS.test(model);
|
|
55
|
+
const effort = EFFORT_MODELS.test(model) ? process.env.NOU_EFFORT ?? requested ?? "high" : undefined;
|
|
56
|
+
const body = {
|
|
57
|
+
model,
|
|
58
|
+
max_tokens: 64000,
|
|
59
|
+
system: [{ type: "text", text: system, cache_control: { type: "ephemeral" } }],
|
|
60
|
+
messages: toAnthropic(messages),
|
|
61
|
+
tools: tools.map((t) => ({
|
|
62
|
+
name: t.name,
|
|
63
|
+
description: t.description,
|
|
64
|
+
input_schema: t.parameters,
|
|
65
|
+
eager_input_streaming: true,
|
|
66
|
+
})),
|
|
67
|
+
stream: true,
|
|
68
|
+
...(effort ? { output_config: { effort: effort } } : {}),
|
|
69
|
+
...(fallbacks ? { betas: ["server-side-fallback-2026-07-01"], fallbacks: "default" } : {}),
|
|
70
|
+
};
|
|
71
|
+
for (let attempt = 0;; attempt++) {
|
|
72
|
+
let text = "";
|
|
73
|
+
try {
|
|
74
|
+
const stream = getClient().beta.messages.stream(body, signal ? { signal } : {});
|
|
75
|
+
stream.on("text", (delta) => {
|
|
76
|
+
text += delta;
|
|
77
|
+
onText(delta);
|
|
78
|
+
});
|
|
79
|
+
const message = await stream.finalMessage();
|
|
80
|
+
const toolCalls = message.content
|
|
81
|
+
.filter((b) => b.type === "tool_use")
|
|
82
|
+
.map((b) => ({ id: b.id, name: b.name, args: (b.input ?? {}) }));
|
|
83
|
+
const stop = message.stop_reason === "refusal"
|
|
84
|
+
? "refusal"
|
|
85
|
+
: message.stop_reason === "max_tokens" && toolCalls.length ? "max_tokens" : undefined;
|
|
86
|
+
return {
|
|
87
|
+
text: message.content.filter((b) => b.type === "text").map((b) => b.text).join(""),
|
|
88
|
+
toolCalls,
|
|
89
|
+
raw: { provider: "anthropic", data: message.content },
|
|
90
|
+
...(stop ? { stop } : {}),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
// With eager input streaming, unparseable tool JSON surfaces as a plain (non-API) error: re-issue a few times.
|
|
95
|
+
if (error instanceof Anthropic.APIError || attempt >= MAX_JSON_RETRIES || signal?.aborted)
|
|
96
|
+
throw error;
|
|
97
|
+
if (text)
|
|
98
|
+
onText("\n");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
async listModels() {
|
|
103
|
+
const models = [];
|
|
104
|
+
for await (const m of getClient().models.list())
|
|
105
|
+
models.push(m.id);
|
|
106
|
+
return models;
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { loadSettings, saveSettings } from "../config/index.js";
|
|
2
|
+
import { anthropicProvider } from "./anthropic.js";
|
|
3
|
+
import { ollamaProvider } from "./ollama.js";
|
|
4
|
+
import { OPENAI_COMPATIBLE, openAICompatibleProvider } from "./openai.js";
|
|
5
|
+
export const providers = [
|
|
6
|
+
ollamaProvider(),
|
|
7
|
+
...OPENAI_COMPATIBLE.filter((p) => p.local).map(openAICompatibleProvider),
|
|
8
|
+
anthropicProvider(),
|
|
9
|
+
...OPENAI_COMPATIBLE.filter((p) => !p.local).map(openAICompatibleProvider),
|
|
10
|
+
];
|
|
11
|
+
/**
|
|
12
|
+
* Parses "provider:model" (e.g. "anthropic:claude-opus-5-5", "ollama:qwen3:8b").
|
|
13
|
+
* A bare provider id picks its default model; anything else is treated as an Ollama model name.
|
|
14
|
+
*/
|
|
15
|
+
export function resolveModel(spec) {
|
|
16
|
+
const trimmed = spec.trim();
|
|
17
|
+
const colon = trimmed.indexOf(":");
|
|
18
|
+
const head = colon >= 0 ? trimmed.slice(0, colon) : trimmed;
|
|
19
|
+
const provider = providers.find((p) => p.id === head.toLowerCase());
|
|
20
|
+
if (provider) {
|
|
21
|
+
const model = colon >= 0 ? trimmed.slice(colon + 1) : "";
|
|
22
|
+
return { provider, model: model || provider.defaultModel };
|
|
23
|
+
}
|
|
24
|
+
return { provider: providers[0], model: trimmed };
|
|
25
|
+
}
|
|
26
|
+
export const describe = (choice) => `${choice.provider.id}:${choice.model}`;
|
|
27
|
+
/** NOU_MODEL wins, then the model saved in settings, then local Ollama. */
|
|
28
|
+
export function initialModel() {
|
|
29
|
+
if (process.env.NOU_MODEL)
|
|
30
|
+
return resolveModel(process.env.NOU_MODEL);
|
|
31
|
+
return resolveModel(loadSettings().model || "ollama");
|
|
32
|
+
}
|
|
33
|
+
export function saveModel(choice) {
|
|
34
|
+
saveSettings({ model: describe(choice) });
|
|
35
|
+
}
|
|
36
|
+
/** One-shot completion without tools, used for things like generating NOU.md. */
|
|
37
|
+
export async function complete(choice, prompt) {
|
|
38
|
+
const result = await choice.provider.chat({
|
|
39
|
+
model: choice.model,
|
|
40
|
+
system: "You are a helpful assistant.",
|
|
41
|
+
messages: [{ role: "user", content: prompt }],
|
|
42
|
+
tools: [],
|
|
43
|
+
onText: () => { },
|
|
44
|
+
});
|
|
45
|
+
return result.text;
|
|
46
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Ollama } from "ollama";
|
|
2
|
+
export function ollamaProvider() {
|
|
3
|
+
const host = process.env.OLLAMA_HOST ?? "http://127.0.0.1:11434";
|
|
4
|
+
const client = new Ollama({ host });
|
|
5
|
+
const toOllama = (system, messages) => [
|
|
6
|
+
{ role: "system", content: system },
|
|
7
|
+
...messages.map((m) => {
|
|
8
|
+
if (m.role === "user")
|
|
9
|
+
return { role: "user", content: m.content };
|
|
10
|
+
if (m.role === "tool")
|
|
11
|
+
return { role: "tool", content: m.content, tool_name: m.name };
|
|
12
|
+
return {
|
|
13
|
+
role: "assistant",
|
|
14
|
+
content: m.content,
|
|
15
|
+
tool_calls: m.toolCalls.map((c) => ({ function: { name: c.name, arguments: c.args } })),
|
|
16
|
+
};
|
|
17
|
+
}),
|
|
18
|
+
];
|
|
19
|
+
return {
|
|
20
|
+
id: "ollama",
|
|
21
|
+
label: `Ollama (${host})`,
|
|
22
|
+
local: true,
|
|
23
|
+
defaultModel: "qwen3:8b",
|
|
24
|
+
unavailable: () => null,
|
|
25
|
+
async chat({ model, system, messages, tools, onText, signal }) {
|
|
26
|
+
const stream = await client.chat({
|
|
27
|
+
model,
|
|
28
|
+
messages: toOllama(system, messages),
|
|
29
|
+
tools: tools.map((t) => ({ type: "function", function: t })),
|
|
30
|
+
stream: true,
|
|
31
|
+
});
|
|
32
|
+
const onAbort = () => stream.abort();
|
|
33
|
+
signal?.addEventListener("abort", onAbort);
|
|
34
|
+
let text = "";
|
|
35
|
+
const toolCalls = [];
|
|
36
|
+
try {
|
|
37
|
+
for await (const part of stream) {
|
|
38
|
+
if (part.message.content) {
|
|
39
|
+
text += part.message.content;
|
|
40
|
+
onText(part.message.content);
|
|
41
|
+
}
|
|
42
|
+
for (const call of part.message.tool_calls ?? []) {
|
|
43
|
+
toolCalls.push({ id: `call_${toolCalls.length}`, name: call.function.name, args: call.function.arguments ?? {} });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
signal?.removeEventListener("abort", onAbort);
|
|
49
|
+
}
|
|
50
|
+
return { text, toolCalls };
|
|
51
|
+
},
|
|
52
|
+
async listModels() {
|
|
53
|
+
const { models } = await client.list();
|
|
54
|
+
return models.map((m) => m.name);
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
/** Everything that speaks the OpenAI chat completions API. */
|
|
3
|
+
export const OPENAI_COMPATIBLE = [
|
|
4
|
+
{ id: "openai", label: "OpenAI", baseURL: "https://api.openai.com/v1", keyEnv: "OPENAI_API_KEY", defaultModel: "gpt-5", local: false },
|
|
5
|
+
{ id: "gemini", label: "Google Gemini", baseURL: "https://generativelanguage.googleapis.com/v1beta/openai/", keyEnv: "GEMINI_API_KEY", defaultModel: "gemini-2.5-pro", local: false },
|
|
6
|
+
{ id: "openrouter", label: "OpenRouter", baseURL: "https://openrouter.ai/api/v1", keyEnv: "OPENROUTER_API_KEY", defaultModel: "anthropic/claude-opus-5-5", local: false },
|
|
7
|
+
{ id: "groq", label: "Groq", baseURL: "https://api.groq.com/openai/v1", keyEnv: "GROQ_API_KEY", defaultModel: "llama-3.3-70b-versatile", local: false },
|
|
8
|
+
{ id: "deepseek", label: "DeepSeek", baseURL: "https://api.deepseek.com/v1", keyEnv: "DEEPSEEK_API_KEY", defaultModel: "deepseek-chat", local: false },
|
|
9
|
+
{ id: "lmstudio", label: "LM Studio", baseURL: "http://localhost:1234/v1", urlEnv: "LMSTUDIO_BASE_URL", defaultModel: "local-model", local: true },
|
|
10
|
+
{ id: "llamacpp", label: "llama.cpp server", baseURL: "http://localhost:8080/v1", urlEnv: "LLAMACPP_BASE_URL", defaultModel: "local-model", local: true },
|
|
11
|
+
{ id: "custom", label: "Custom OpenAI-compatible", baseURL: "", urlEnv: "NOU_BASE_URL", keyEnv: "NOU_API_KEY", defaultModel: "default", local: true },
|
|
12
|
+
];
|
|
13
|
+
function toOpenAI(system, messages) {
|
|
14
|
+
const out = [{ role: "system", content: system }];
|
|
15
|
+
for (const m of messages) {
|
|
16
|
+
if (m.role === "user")
|
|
17
|
+
out.push({ role: "user", content: m.content });
|
|
18
|
+
else if (m.role === "tool")
|
|
19
|
+
out.push({ role: "tool", tool_call_id: m.callId, content: m.content });
|
|
20
|
+
else {
|
|
21
|
+
out.push({
|
|
22
|
+
role: "assistant",
|
|
23
|
+
content: m.content || null,
|
|
24
|
+
...(m.toolCalls.length
|
|
25
|
+
? {
|
|
26
|
+
tool_calls: m.toolCalls.map((c) => ({
|
|
27
|
+
id: c.id,
|
|
28
|
+
type: "function",
|
|
29
|
+
function: { name: c.name, arguments: JSON.stringify(c.args) },
|
|
30
|
+
})),
|
|
31
|
+
}
|
|
32
|
+
: {}),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
export function openAICompatibleProvider(preset) {
|
|
39
|
+
const baseURL = (preset.urlEnv && process.env[preset.urlEnv]) || preset.baseURL;
|
|
40
|
+
const apiKey = (preset.keyEnv && process.env[preset.keyEnv]) || (preset.local ? "not-needed" : "");
|
|
41
|
+
let client = null;
|
|
42
|
+
const getClient = () => (client ??= new OpenAI({ baseURL, apiKey }));
|
|
43
|
+
return {
|
|
44
|
+
id: preset.id,
|
|
45
|
+
label: preset.label,
|
|
46
|
+
local: preset.local,
|
|
47
|
+
defaultModel: preset.defaultModel,
|
|
48
|
+
unavailable() {
|
|
49
|
+
if (!baseURL)
|
|
50
|
+
return `set ${preset.urlEnv}`;
|
|
51
|
+
if (!apiKey)
|
|
52
|
+
return `set ${preset.keyEnv}`;
|
|
53
|
+
return null;
|
|
54
|
+
},
|
|
55
|
+
async chat({ model, system, messages, tools, onText, signal }) {
|
|
56
|
+
const stream = await getClient().chat.completions.create({
|
|
57
|
+
model,
|
|
58
|
+
messages: toOpenAI(system, messages),
|
|
59
|
+
...(tools.length ? { tools: tools.map((t) => ({ type: "function", function: t })) } : {}),
|
|
60
|
+
stream: true,
|
|
61
|
+
}, signal ? { signal } : {});
|
|
62
|
+
let text = "";
|
|
63
|
+
let finish = null;
|
|
64
|
+
// Tool calls stream in fragments keyed by index.
|
|
65
|
+
const partial = new Map();
|
|
66
|
+
for await (const chunk of stream) {
|
|
67
|
+
const choice = chunk.choices[0];
|
|
68
|
+
if (!choice)
|
|
69
|
+
continue;
|
|
70
|
+
if (choice.delta.content) {
|
|
71
|
+
text += choice.delta.content;
|
|
72
|
+
onText(choice.delta.content);
|
|
73
|
+
}
|
|
74
|
+
for (const delta of choice.delta.tool_calls ?? []) {
|
|
75
|
+
const call = partial.get(delta.index) ?? { id: "", name: "", args: "" };
|
|
76
|
+
if (delta.id)
|
|
77
|
+
call.id = delta.id;
|
|
78
|
+
if (delta.function?.name)
|
|
79
|
+
call.name += delta.function.name;
|
|
80
|
+
if (delta.function?.arguments)
|
|
81
|
+
call.args += delta.function.arguments;
|
|
82
|
+
partial.set(delta.index, call);
|
|
83
|
+
}
|
|
84
|
+
if (choice.finish_reason)
|
|
85
|
+
finish = choice.finish_reason;
|
|
86
|
+
}
|
|
87
|
+
const toolCalls = [...partial.values()].map((c, i) => {
|
|
88
|
+
let args;
|
|
89
|
+
try {
|
|
90
|
+
args = c.args ? JSON.parse(c.args) : {};
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
args = { __invalid_json: c.args };
|
|
94
|
+
}
|
|
95
|
+
return { id: c.id || `call_${i}`, name: c.name, args };
|
|
96
|
+
});
|
|
97
|
+
return { text, toolCalls, ...(finish === "length" && toolCalls.length ? { stop: "max_tokens" } : {}) };
|
|
98
|
+
},
|
|
99
|
+
async listModels() {
|
|
100
|
+
const models = [];
|
|
101
|
+
for await (const m of getClient().models.list())
|
|
102
|
+
models.push(m.id);
|
|
103
|
+
return models.sort();
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
export const tools = [
|
|
2
|
+
{
|
|
3
|
+
type: "function",
|
|
4
|
+
function: {
|
|
5
|
+
name: "run_command",
|
|
6
|
+
description: "Run a terminal command",
|
|
7
|
+
parameters: {
|
|
8
|
+
type: "object",
|
|
9
|
+
properties: {
|
|
10
|
+
command: {
|
|
11
|
+
type: "string",
|
|
12
|
+
description: "The shell command to run",
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
required: ["command"]
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
type: "function",
|
|
21
|
+
function: {
|
|
22
|
+
name: "read_file",
|
|
23
|
+
description: "Read the contents of a file",
|
|
24
|
+
parameters: {
|
|
25
|
+
type: "object",
|
|
26
|
+
properties: {
|
|
27
|
+
path: {
|
|
28
|
+
type: "string",
|
|
29
|
+
description: "Path to file",
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
required: ["path"]
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
type: "function",
|
|
38
|
+
function: {
|
|
39
|
+
name: "list_files",
|
|
40
|
+
description: "List the files and folders of the project (or a sub folder) as a tree",
|
|
41
|
+
parameters: {
|
|
42
|
+
type: "object",
|
|
43
|
+
properties: {
|
|
44
|
+
path: {
|
|
45
|
+
type: "string",
|
|
46
|
+
description: "Folder to list, relative to the project root. Defaults to the root.",
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
required: []
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
type: "function",
|
|
55
|
+
function: {
|
|
56
|
+
name: "edit_file",
|
|
57
|
+
description: "Edit an existing file by replacing one exact snippet of text with new text. Prefer this over write_file for changes to existing files. Read the file first so old_string matches exactly.",
|
|
58
|
+
parameters: {
|
|
59
|
+
type: "object",
|
|
60
|
+
properties: {
|
|
61
|
+
path: {
|
|
62
|
+
type: "string",
|
|
63
|
+
description: "Path to the file to edit"
|
|
64
|
+
},
|
|
65
|
+
old_string: {
|
|
66
|
+
type: "string",
|
|
67
|
+
description: "The exact text to replace. Must appear exactly once in the file."
|
|
68
|
+
},
|
|
69
|
+
new_string: {
|
|
70
|
+
type: "string",
|
|
71
|
+
description: "The text to put in its place"
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
required: ["path", "old_string", "new_string"]
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
type: "function",
|
|
80
|
+
function: {
|
|
81
|
+
name: "write_file",
|
|
82
|
+
description: "Create a new file or fully overwrite a file (TypeScript, JavaScript, Python, etc.). Use this to generate boilerplate, add features, or create scripts.",
|
|
83
|
+
parameters: {
|
|
84
|
+
type: "object",
|
|
85
|
+
properties: {
|
|
86
|
+
path: {
|
|
87
|
+
type: "string",
|
|
88
|
+
description: "The relative path including extension (e.g., \"scripts/setup.py\")"
|
|
89
|
+
},
|
|
90
|
+
content: {
|
|
91
|
+
type: "string",
|
|
92
|
+
description: "The RAW source code. Do NOT wrap in markdown code blocks. Use real newlines, not escaped \\n characters."
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
required: ["path", "content"]
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
];
|
|
100
|
+
export const toolDefs = tools.map((t) => t.function);
|