@hncode/hncode 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/LICENSE +219 -0
- package/README.md +330 -0
- package/TODO.md +72 -0
- package/bin/hncode +9 -0
- package/bin/hncode.cmd +2 -0
- package/bin/hncode.ps1 +4 -0
- package/bin/install.js +96 -0
- package/bin/preinstall.js +42 -0
- package/package.json +26 -0
- package/src/agent.js +373 -0
- package/src/clipboard.js +114 -0
- package/src/colors.js +149 -0
- package/src/config.js +533 -0
- package/src/index.js +296 -0
- package/src/llm.js +449 -0
- package/src/plugin.js +191 -0
- package/src/session.js +73 -0
- package/src/term.js +292 -0
- package/src/toml.js +281 -0
- package/src/tools/bash.js +240 -0
- package/src/tools/edit.js +138 -0
- package/src/tools/fetch-url.js +108 -0
- package/src/tools/file-lines.js +36 -0
- package/src/tools/glob.js +47 -0
- package/src/tools/grep.js +152 -0
- package/src/tools/ignore.js +99 -0
- package/src/tools/index.js +61 -0
- package/src/tools/matchers.js +71 -0
- package/src/tools/read-media-file.js +54 -0
- package/src/tools/read.js +99 -0
- package/src/tools/tasks.js +126 -0
- package/src/tools/todo.js +33 -0
- package/src/tools/utils.js +71 -0
- package/src/tools/web-search.js +75 -0
- package/src/tools/write.js +29 -0
- package/src/tui.js +5503 -0
package/src/colors.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Cyan-blue visual theme for hncode. Adapts to the terminal's color depth:
|
|
2
|
+
// uses true-color when available, 256/16-color fallbacks otherwise.
|
|
3
|
+
|
|
4
|
+
function colorDepth() {
|
|
5
|
+
try { return process.stdout.getColorDepth?.() || 0; } catch { return 0; }
|
|
6
|
+
}
|
|
7
|
+
const DEPTH = colorDepth();
|
|
8
|
+
export const TRUECOLOR = DEPTH >= 24 || process.env.COLORTERM === 'truecolor' || /^xterm.*-256color$|.*-truecolor$/.test(process.env.TERM || '');
|
|
9
|
+
|
|
10
|
+
function rgb(r, g, b) { return TRUECOLOR ? `\x1b[38;2;${r};${g};${b}m` : `\x1b[38;5;${ansi256(r, g, b)}m`; }
|
|
11
|
+
function brgb(r, g, b) { return TRUECOLOR ? `\x1b[48;2;${r};${g};${b}m` : `\x1b[48;5;${ansi256(r, g, b)}m`; }
|
|
12
|
+
|
|
13
|
+
// Quantize a truecolor triple to the nearest 256-color xterm index.
|
|
14
|
+
function ansi256(r, g, b) {
|
|
15
|
+
const ri = Math.round(r / 51), gi = Math.round(g / 51), bi = Math.round(b / 51);
|
|
16
|
+
if (ri === gi && gi === bi) {
|
|
17
|
+
if (ri === 0) return 16;
|
|
18
|
+
if (ri === 255 / 51) return 232 + ri;
|
|
19
|
+
return 232 + ri; // grayscale ramp
|
|
20
|
+
}
|
|
21
|
+
return 16 + 36 * ri + 6 * gi + bi;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Brand: cyan-blue (青蓝色).
|
|
25
|
+
export const TEAL = rgb(0, 184, 219); // 青蓝
|
|
26
|
+
export const CYAN = rgb(0, 215, 255);
|
|
27
|
+
export const BLUE = rgb(66, 135, 245);
|
|
28
|
+
export const FG = TEAL; // primary fg
|
|
29
|
+
export const BG = rgb(0, 18, 26);
|
|
30
|
+
export const BORDER = rgb(70, 130, 180);
|
|
31
|
+
|
|
32
|
+
// Named accent colours per theme. `auto` keeps the brand cyan-blue and lets the
|
|
33
|
+
// terminal's own palette show through for everything else; `dark`/`light` pick
|
|
34
|
+
// accent triples that stay readable on that background.
|
|
35
|
+
const THEMES = {
|
|
36
|
+
auto: { teal: [0, 184, 219], cyan: [0, 215, 255], blue: [66, 135, 245], border: [70, 130, 180], hover: [190, 245, 255] },
|
|
37
|
+
dark: { teal: [0, 200, 230], cyan: [90, 225, 255], blue: [90, 150, 255], border: [80, 140, 190], hover: [190, 245, 255] },
|
|
38
|
+
light: { teal: [0, 120, 160], cyan: [0, 140, 200], blue: [40, 90, 200], border: [120, 160, 200], hover: [0, 80, 110] },
|
|
39
|
+
};
|
|
40
|
+
export const THEME_NAMES = Object.keys(THEMES);
|
|
41
|
+
|
|
42
|
+
export const C = {
|
|
43
|
+
reset: '\x1b[0m',
|
|
44
|
+
bold: '\x1b[1m',
|
|
45
|
+
dim: '\x1b[2m',
|
|
46
|
+
muted: '\x1b[90m',
|
|
47
|
+
teal: TEAL,
|
|
48
|
+
cyan: CYAN,
|
|
49
|
+
blue: BLUE,
|
|
50
|
+
fg: FG,
|
|
51
|
+
bg: BG,
|
|
52
|
+
bgTeal: brgb(0, 184, 219),
|
|
53
|
+
bgDim: brgb(12, 24, 30),
|
|
54
|
+
bgPanel: TRUECOLOR ? '\x1b[48;5;236m' : '\x1b[48;5;236m',
|
|
55
|
+
border: BORDER,
|
|
56
|
+
red: '\x1b[91m',
|
|
57
|
+
green: '\x1b[92m',
|
|
58
|
+
yellow: '\x1b[93m',
|
|
59
|
+
magenta: '\x1b[95m',
|
|
60
|
+
gray: '\x1b[90m',
|
|
61
|
+
white: '\x1b[97m',
|
|
62
|
+
orange: TRUECOLOR ? rgb(255, 140, 0) : '\x1b[38;5;208m',
|
|
63
|
+
bgRed: '\x1b[41m',
|
|
64
|
+
bgGreen: '\x1b[42m',
|
|
65
|
+
// Selection highlight (mouse text selection) and the scrollbar gutter.
|
|
66
|
+
// Selection uses a dark teal background so white text stays readable.
|
|
67
|
+
selBg: TRUECOLOR ? brgb(0, 70, 84) : '\x1b[48;5;23m', // Original dark cyan background
|
|
68
|
+
scrollTrack: '\x1b[90m', // dim gutter
|
|
69
|
+
scrollThumb: TRUECOLOR ? rgb(0, 184, 219) : '\x1b[38;5;37m', // brand cyan-blue
|
|
70
|
+
// Scrollbar interaction states. hover: a touch whiter/brighter than the brand
|
|
71
|
+
// colour; active (pressed/dragging): a touch darker. Idle returns to
|
|
72
|
+
// scrollThumb (the brand colour) — the caller swaps back automatically.
|
|
73
|
+
scrollThumbHover: TRUECOLOR ? rgb(120, 226, 245) : '\x1b[38;5;123m',
|
|
74
|
+
scrollThumbActive: TRUECOLOR ? rgb(0, 140, 168) : '\x1b[38;5;31m',
|
|
75
|
+
// Hover highlight for clickable rows (menu items, picker options, form
|
|
76
|
+
// fields, composer rows). An explicit bright foreground + BOLD rather than a
|
|
77
|
+
// bare BOLD: BOLD only brightens the 16-colour palette (it does nothing to a
|
|
78
|
+
// true-color row), which is why hover used to be invisible on most rows.
|
|
79
|
+
// tintRange() re-applies this so the row's own SGR codes cannot cancel it.
|
|
80
|
+
hover: '\x1b[1m' + rgb(190, 245, 255),
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export function style(text, ...codes) {
|
|
84
|
+
return codes.join('') + text + '\x1b[0m';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Linearly interpolate between two RGB triples and return an ANSI colour string.
|
|
88
|
+
// Used for the pulsing Working indicator (orange pulses to a brighter/darker
|
|
89
|
+
// shade over a 2s cycle).
|
|
90
|
+
export function lerpColor(r1, g1, b1, r2, g2, b2, t) {
|
|
91
|
+
const ti = Math.max(0, Math.min(1, t));
|
|
92
|
+
const r = Math.round(r1 + (r2 - r1) * ti);
|
|
93
|
+
const g = Math.round(g1 + (g2 - g1) * ti);
|
|
94
|
+
const b = Math.round(b1 + (b2 - b1) * ti);
|
|
95
|
+
return rgb(r, g, b);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Apply a named theme IN PLACE. `C` is a module-level object and ESM imports are
|
|
99
|
+
// live bindings, so mutating its properties here changes the colours everywhere
|
|
100
|
+
// without re-importing. Returns true when the name was recognised.
|
|
101
|
+
export function setTheme(name) {
|
|
102
|
+
const t = THEMES[name];
|
|
103
|
+
if (!t) return false;
|
|
104
|
+
C.teal = rgb(...t.teal);
|
|
105
|
+
C.fg = C.teal;
|
|
106
|
+
C.cyan = rgb(...t.cyan);
|
|
107
|
+
C.blue = rgb(...t.blue);
|
|
108
|
+
C.border = rgb(...t.border);
|
|
109
|
+
C.bgTeal = brgb(...t.teal);
|
|
110
|
+
// Hover stays visible on this theme's background (bright on dark, dark on light).
|
|
111
|
+
C.hover = '\x1b[1m' + rgb(...(t.hover || [190, 245, 255]));
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function clearScreen() {
|
|
116
|
+
return '\x1b[3J\x1b[H\x1b[2J';
|
|
117
|
+
}
|
|
118
|
+
export function eraseLine() {
|
|
119
|
+
return '\x1b[2K';
|
|
120
|
+
}
|
|
121
|
+
export function eraseScreen() {
|
|
122
|
+
return '\x1b[2J';
|
|
123
|
+
}
|
|
124
|
+
export function moveTo(r, c) {
|
|
125
|
+
return `\x1b[${r + 1};${c + 1}H`;
|
|
126
|
+
}
|
|
127
|
+
export function cursorUp(n) { return `\x1b[${n}A`; }
|
|
128
|
+
export function cursorDown(n) { return `\x1b[${n}B`; }
|
|
129
|
+
export function cursorLeft(n) { return `\x1b[${n}D`; }
|
|
130
|
+
export function cursorForward(n) { return `\x1b[${n}C`; }
|
|
131
|
+
export function hideCursor() { return '\x1b[?25l'; }
|
|
132
|
+
export function showCursor() { return '\x1b[?25h'; }
|
|
133
|
+
// Cursor shape: terminals accept DECSCUSR — 2 = block, 4 = underline, 6 = bar(I-beam);
|
|
134
|
+
// 1/3/5 = blinking variants. Force a solid block cursor so positioning is always exact
|
|
135
|
+
// and unaffected by I-beam alignment.
|
|
136
|
+
export function blockCursor() { return '\x1b[2 q'; }
|
|
137
|
+
export function underlineCursor() { return '\x1b[4 q'; }
|
|
138
|
+
export function barCursor() { return '\x1b[6 q'; }
|
|
139
|
+
export function alternateScreen(on) { return on ? '\x1b[?1049h' : '\x1b[?1049l'; }
|
|
140
|
+
export function setScrollRegion(top, bottom) {
|
|
141
|
+
if (process.platform === 'win32') return ''; // win TTY scroll regions are unreliable
|
|
142
|
+
return `\x1b[${top + 1};${bottom + 1}r`;
|
|
143
|
+
}
|
|
144
|
+
export function resetScrollRegion() {
|
|
145
|
+
return '\x1b[r';
|
|
146
|
+
}
|
|
147
|
+
export function setBracketed(on) {
|
|
148
|
+
return `\x1b[?2004h${on ? '' : ''}`; // placeholder; real impl toggles bracketed paste
|
|
149
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
// hncode configuration — fully independent of kimi-code-cli.
|
|
2
|
+
// Loads ONLY ~/.hncode/config.toml (plus HNCODE_* env overrides). It does NOT
|
|
3
|
+
// read ~/.kimi-code/config.toml; hncode and kimi-code are separate products.
|
|
4
|
+
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import { parse } from './toml.js';
|
|
9
|
+
|
|
10
|
+
const home = os.homedir();
|
|
11
|
+
|
|
12
|
+
export const DEFAULTS = {
|
|
13
|
+
model: '',
|
|
14
|
+
provider: '',
|
|
15
|
+
baseUrl: '',
|
|
16
|
+
apiKey: '',
|
|
17
|
+
protocol: 'openai',
|
|
18
|
+
// Fallback context window when neither config nor /v1/models provides one.
|
|
19
|
+
maxContextTokens: 512000,
|
|
20
|
+
maxOutputTokens: 4096,
|
|
21
|
+
reasoning: false,
|
|
22
|
+
workspace: process.cwd(),
|
|
23
|
+
allowExternal: false,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Provider/model catalog upstream — the same source kimi-code uses. Fetched by
|
|
27
|
+
// `/provider` → Add so the user can pick a known provider (with its base_url)
|
|
28
|
+
// or type a custom one. Shape: { [providerId]: { id, name, api, doc, env,
|
|
29
|
+
// models: { [modelId]: { id, name, limit: { context, output }, reasoning, … } } } }.
|
|
30
|
+
export const MODELS_DEV_URL = 'https://models.dev/api.json';
|
|
31
|
+
let catalogCache = null;
|
|
32
|
+
let catalogInFlight = null;
|
|
33
|
+
const CATALOG_TTL_MS = 10 * 60 * 1000;
|
|
34
|
+
let catalogFetchedAt = 0;
|
|
35
|
+
|
|
36
|
+
// Fetch (and memoize) the models.dev catalog. Returns null on failure.
|
|
37
|
+
export async function fetchCatalog() {
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
if (catalogCache && now - catalogFetchedAt < CATALOG_TTL_MS) return catalogCache;
|
|
40
|
+
if (catalogInFlight) return catalogInFlight;
|
|
41
|
+
catalogInFlight = (async () => {
|
|
42
|
+
try {
|
|
43
|
+
const ctrl = new AbortController();
|
|
44
|
+
const timer = setTimeout(() => ctrl.abort(), 15000);
|
|
45
|
+
const res = await fetch(MODELS_DEV_URL, { signal: ctrl.signal });
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
if (!res.ok) return null;
|
|
48
|
+
const json = await res.json();
|
|
49
|
+
if (json && typeof json === 'object') {
|
|
50
|
+
catalogCache = json;
|
|
51
|
+
catalogFetchedAt = Date.now();
|
|
52
|
+
return json;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
} catch { return null; }
|
|
56
|
+
finally { catalogInFlight = null; }
|
|
57
|
+
})();
|
|
58
|
+
return catalogInFlight;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function hncodeConfigFile() {
|
|
62
|
+
return process.env.HNCODE_CONFIG || path.join(home, '.hncode', 'config.toml');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function loadBaseToml() {
|
|
66
|
+
// Only hncode's own config file participates.
|
|
67
|
+
const f = hncodeConfigFile();
|
|
68
|
+
let base = {};
|
|
69
|
+
try {
|
|
70
|
+
if (fs.statSync(f).isFile()) base = parse(fs.readFileSync(f, 'utf8'));
|
|
71
|
+
} catch (e) {
|
|
72
|
+
if (e.code !== 'ENOENT') console.error(`hncode: failed to parse ${f}: ${e.message}`);
|
|
73
|
+
}
|
|
74
|
+
return base;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function resolveConfig() {
|
|
78
|
+
const root = loadBaseToml() || {};
|
|
79
|
+
|
|
80
|
+
const providers = root.providers || {};
|
|
81
|
+
const models = root.models || {};
|
|
82
|
+
let model = process.env.HNCODE_MODEL || root.model || root.default_model || DEFAULTS.model;
|
|
83
|
+
// No hard-coded provider: fall back to the first one the user configured.
|
|
84
|
+
let providerName = process.env.HNCODE_PROVIDER || root.provider || Object.keys(providers)[0] || DEFAULTS.provider;
|
|
85
|
+
// If no model was named but models exist, adopt the first one under the
|
|
86
|
+
// active provider (so the status line always has a model to show).
|
|
87
|
+
if (!model) {
|
|
88
|
+
const first = Object.keys(models).find((k) => (models[k].provider || '') === providerName)
|
|
89
|
+
|| Object.keys(models)[0];
|
|
90
|
+
if (first) model = first;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let baseUrl = process.env.HNCODE_BASE_URL || root.base_url || root.baseUrl || '';
|
|
94
|
+
let apiKey = process.env.HNCODE_API_KEY || root.api_key || root.apiKey || '';
|
|
95
|
+
let protocol = process.env.HNCODE_PROTOCOL || root.protocol || '';
|
|
96
|
+
// The model actually sent to the API: the [models.*] entry's `model` field
|
|
97
|
+
// when the key names a pool entry, otherwise the literal name.
|
|
98
|
+
const modelEntry = models[model] || {};
|
|
99
|
+
let innerModel = bareModelId(modelEntry.provider || providerName, model, modelEntry);
|
|
100
|
+
|
|
101
|
+
// A [models.*] entry names its own provider; honour it.
|
|
102
|
+
if (modelEntry.provider) providerName = modelEntry.provider;
|
|
103
|
+
|
|
104
|
+
// Optional provider sub-tables: [providers.NAME] base_url / api_key / protocol.
|
|
105
|
+
const prov = providers[providerName];
|
|
106
|
+
if (prov) {
|
|
107
|
+
// Provider sub-table supplements env/root; an explicit HNCODE_* env var
|
|
108
|
+
// always wins, otherwise the provider table fills in the gaps it declares.
|
|
109
|
+
baseUrl = process.env.HNCODE_BASE_URL || (prov.base_url || prov.baseUrl) || baseUrl;
|
|
110
|
+
apiKey = process.env.HNCODE_API_KEY || (prov.api_key || prov.apiKey) || apiKey;
|
|
111
|
+
protocol = process.env.HNCODE_PROTOCOL || (prov.protocol) || protocol;
|
|
112
|
+
innerModel = prov.model || innerModel;
|
|
113
|
+
}
|
|
114
|
+
protocol = protocol || DEFAULTS.protocol;
|
|
115
|
+
|
|
116
|
+
// The base URL is used EXACTLY as configured — we only append the API path.
|
|
117
|
+
// (No `/v1` is injected: if the user's base ends in /v1, the result is
|
|
118
|
+
// `<base>/chat/completions`; if it doesn't, it's `<base>/chat/completions` too.)
|
|
119
|
+
const b = baseUrl.replace(/\/+$/, '');
|
|
120
|
+
const endpoint = protocol === 'anthropic' ? `${b}/messages` : `${b}/chat/completions`;
|
|
121
|
+
|
|
122
|
+
// Context window: an explicit env/root override wins, then the model's own
|
|
123
|
+
// `contextLength` (discovered from the provider's /v1/models), then the
|
|
124
|
+
// default. This is what stops every model from being pinned to 128k.
|
|
125
|
+
const overrideContext = process.env.HNCODE_MAX_CONTEXT || root.max_context_size || root.max_context_tokens;
|
|
126
|
+
const modelContext = Number(modelEntry.contextLength || modelEntry.context_length || modelEntry.max_context_size || 0);
|
|
127
|
+
const maxContext = Number(overrideContext || modelContext || DEFAULTS.maxContextTokens);
|
|
128
|
+
const maxOutput = Number(process.env.HNCODE_MAX_OUTPUT || root.max_output_size || DEFAULTS.maxOutputTokens);
|
|
129
|
+
const reasoning = process.env.HNCODE_REASONING ? /^(1|true|yes)$/i.test(process.env.HNCODE_REASONING) : !!root.reasoning;
|
|
130
|
+
const allowExternal = process.env.HNCODE_ALLOW_EXTERNAL === '1' || !!root.tool_allow_external_paths;
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
model, provider: providerName, innerModel,
|
|
134
|
+
baseUrl: b, endpoint, apiKey, protocol,
|
|
135
|
+
maxContextTokens: maxContext, maxOutputTokens: maxOutput, reasoning,
|
|
136
|
+
workspace: process.env.HNCODE_WORKSPACE || root.workspace || DEFAULTS.workspace,
|
|
137
|
+
allowExternal,
|
|
138
|
+
// A user-supplied system prompt (set via /set-system-prompt). Empty means
|
|
139
|
+
// "use the built-in SYSTEM_PROMPT". Read from config.toml so it persists
|
|
140
|
+
// across restarts.
|
|
141
|
+
systemPrompt: process.env.HNCODE_SYSTEM_PROMPT || root.system_prompt || '',
|
|
142
|
+
// CALM MODE (/calm-mode): persisted boolean. When true an instruction is
|
|
143
|
+
// appended to the system prompt to suppress narration.
|
|
144
|
+
calmMode: process.env.HNCODE_CALM_MODE
|
|
145
|
+
? /^(1|true|yes|on)$/i.test(process.env.HNCODE_CALM_MODE)
|
|
146
|
+
// Backwards-compat: read legacy cool_mode key if calm_mode is not set.
|
|
147
|
+
: (root.calm_mode === true || root.calm_mode === 'true'
|
|
148
|
+
|| root.cool_mode === true || root.cool_mode === 'true'),
|
|
149
|
+
// Plugin directory (default: ~/.hncode/plugins). Set to "" or "false" to
|
|
150
|
+
// disable plugin loading entirely.
|
|
151
|
+
pluginDir: process.env.HNCODE_PLUGINS || root.plugins_dir || '',
|
|
152
|
+
raw: root,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function saveConfig(overlay) {
|
|
157
|
+
const dir = hncodeConfigFile();
|
|
158
|
+
let out = '';
|
|
159
|
+
for (const [k, v] of Object.entries(overlay)) out += toTomlLine(k, v) + '\n';
|
|
160
|
+
fs.mkdirSync(path.dirname(dir), { recursive: true });
|
|
161
|
+
|
|
162
|
+
// Check if file exists and has content
|
|
163
|
+
try {
|
|
164
|
+
if (fs.statSync(dir).isFile()) {
|
|
165
|
+
const existing = fs.readFileSync(dir, 'utf8');
|
|
166
|
+
if (existing.trim().length > 0) {
|
|
167
|
+
// Append to existing config instead of overwriting
|
|
168
|
+
fs.writeFileSync(dir, existing + '\n' + out, 'utf8');
|
|
169
|
+
return dir;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
} catch {}
|
|
173
|
+
|
|
174
|
+
// New file or empty file
|
|
175
|
+
fs.writeFileSync(dir, out, 'utf8');
|
|
176
|
+
return dir;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Persist a top-level string key in config.toml, replacing any previous value.
|
|
180
|
+
// Used by /set-system-prompt. Multi-line values are written as a TOML MULTI-LINE
|
|
181
|
+
// BASIC STRING ("""…"""), which keeps newlines readable and means the editor can
|
|
182
|
+
// round-trip a prompt that contains them. An empty value REMOVES the key, so the
|
|
183
|
+
// built-in prompt takes over again.
|
|
184
|
+
export function setConfigString(key, value) {
|
|
185
|
+
const file = hncodeConfigFile();
|
|
186
|
+
let text = '';
|
|
187
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { /* new file */ }
|
|
188
|
+
const lines = text.split(/\r?\n/);
|
|
189
|
+
const out = [];
|
|
190
|
+
let skipping = false; // inside a multi-line value we are dropping
|
|
191
|
+
let replaced = false;
|
|
192
|
+
const keyRe = new RegExp('^\\s*' + escapeRe(key) + '\\s*=');
|
|
193
|
+
// Track the index of the first table header — new root-level keys must be
|
|
194
|
+
// inserted BEFORE any [table] section, or TOML assigns them to the last table.
|
|
195
|
+
let firstTableIdx = -1;
|
|
196
|
+
for (let i = 0; i < lines.length; i++) {
|
|
197
|
+
const ln = lines[i];
|
|
198
|
+
if (skipping) {
|
|
199
|
+
// A multi-line basic string ends at an unescaped """.
|
|
200
|
+
if (/"""\s*$/.test(ln) || /'''\s*$/.test(ln)) skipping = false;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
// Detect a TOML table header line like [foo] or [foo.bar] at column 0.
|
|
204
|
+
if (/^\[/.test(ln) && firstTableIdx < 0) firstTableIdx = out.length;
|
|
205
|
+
if (keyRe.test(ln)) {
|
|
206
|
+
// Drop this key's line, and its body if it opens a multi-line string.
|
|
207
|
+
const rest = ln.slice(ln.indexOf('=') + 1).trim();
|
|
208
|
+
const opensMultiline = /^"""|^'''/.test(rest) && !/^"""[\s\S]*"""\s*$/.test(rest);
|
|
209
|
+
if (opensMultiline) skipping = true;
|
|
210
|
+
if (!replaced && value !== '' && value != null) {
|
|
211
|
+
out.push(...renderTomlString(key, String(value)));
|
|
212
|
+
replaced = true;
|
|
213
|
+
}
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
out.push(ln);
|
|
217
|
+
}
|
|
218
|
+
while (out.length && out[out.length - 1].trim() === '') out.pop();
|
|
219
|
+
if (!replaced && value !== '' && value != null) {
|
|
220
|
+
// Insert new root-level keys BEFORE the first [table] section so they
|
|
221
|
+
// are parsed as root keys, not assigned to a table.
|
|
222
|
+
const rendered = renderTomlString(key, String(value));
|
|
223
|
+
if (firstTableIdx >= 0) {
|
|
224
|
+
out.splice(firstTableIdx, 0, '', ...rendered);
|
|
225
|
+
} else {
|
|
226
|
+
out.push('');
|
|
227
|
+
out.push(...rendered);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
231
|
+
fs.writeFileSync(file, out.join('\n') + (out.length ? '\n' : ''), 'utf8');
|
|
232
|
+
return file;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Render `key = <value>` as TOML. Uses a multi-line basic string when the value
|
|
236
|
+
// contains newlines (readable, and TOML-valid), a plain one otherwise.
|
|
237
|
+
function renderTomlString(key, value) {
|
|
238
|
+
if (value.includes('\n')) {
|
|
239
|
+
// Inside """…""" a backslash must be escaped; quotes are fine unless tripled.
|
|
240
|
+
const body = value.replace(/\\/g, '\\\\').replace(/"""/g, '\\"\\"\\"');
|
|
241
|
+
return [`${key} = """`, ...body.split('\n'), '"""'];
|
|
242
|
+
}
|
|
243
|
+
return [`${key} = "${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Append (or replace) a [providers.<name>] table in config.toml without
|
|
247
|
+
// clobbering unrelated content. Used by /provider's "Add provider…" flow.
|
|
248
|
+
export function addProvider(name, { base_url, api_key, protocol } = {}) {
|
|
249
|
+
const file = hncodeConfigFile();
|
|
250
|
+
let text = '';
|
|
251
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { /* new file */ }
|
|
252
|
+
const lines = text.split(/\r?\n/);
|
|
253
|
+
// drop any existing [providers.<name>] block (and its key lines) so we replace it
|
|
254
|
+
const out = [];
|
|
255
|
+
let skipping = false;
|
|
256
|
+
for (const ln of lines) {
|
|
257
|
+
const m = /^\s*\[providers\.([^\]]+)\]\s*$/.exec(ln);
|
|
258
|
+
if (m) { skipping = m[1] === name; if (skipping) continue; }
|
|
259
|
+
else if (/^\s*\[/.test(ln)) skipping = false;
|
|
260
|
+
if (!skipping) out.push(ln);
|
|
261
|
+
}
|
|
262
|
+
while (out.length && out[out.length - 1].trim() === '') out.pop();
|
|
263
|
+
out.push('');
|
|
264
|
+
out.push(`[providers.${name}]`);
|
|
265
|
+
if (base_url) out.push(`base_url = "${String(base_url).replace(/"/g, '\\"')}"`);
|
|
266
|
+
if (api_key) out.push(`api_key = "${String(api_key).replace(/"/g, '\\"')}"`);
|
|
267
|
+
if (protocol) out.push(`protocol = "${String(protocol).replace(/"/g, '\\"')}"`);
|
|
268
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
269
|
+
fs.writeFileSync(file, out.join('\n') + '\n', 'utf8');
|
|
270
|
+
return file;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Remove a [providers.<name>] table (and its key lines) from config.toml, plus
|
|
274
|
+
// every [models."<name>/..."] entry that belongs to it — otherwise /model keeps
|
|
275
|
+
// listing the deleted provider's models.
|
|
276
|
+
export function removeProvider(name) {
|
|
277
|
+
const file = hncodeConfigFile();
|
|
278
|
+
let text = '';
|
|
279
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return file; }
|
|
280
|
+
const lines = text.split(/\r?\n/);
|
|
281
|
+
const out = [];
|
|
282
|
+
let skipping = false;
|
|
283
|
+
// A models key is `[models."provider/modelId"]`; match the provider prefix.
|
|
284
|
+
const modelRe = /^\s*\[models\."([^"]+)"\]\s*$/;
|
|
285
|
+
for (const ln of lines) {
|
|
286
|
+
const pm = /^\s*\[providers\.([^\]]+)\]\s*$/.exec(ln);
|
|
287
|
+
if (pm) { skipping = pm[1] === name; if (skipping) continue; }
|
|
288
|
+
const mm = modelRe.exec(ln);
|
|
289
|
+
if (mm) {
|
|
290
|
+
// The model key starts with `<provider>/`; skip it if it is this provider's.
|
|
291
|
+
const keyProvider = mm[1].split('/')[0];
|
|
292
|
+
skipping = keyProvider === name;
|
|
293
|
+
if (skipping) continue;
|
|
294
|
+
} else if (/^\s*\[/.test(ln)) {
|
|
295
|
+
skipping = false;
|
|
296
|
+
}
|
|
297
|
+
if (!skipping) out.push(ln);
|
|
298
|
+
}
|
|
299
|
+
while (out.length && out[out.length - 1].trim() === '') out.pop();
|
|
300
|
+
fs.writeFileSync(file, out.join('\n') + (out.length ? '\n' : ''), 'utf8');
|
|
301
|
+
return file;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---- models: fetch + per-provider pools ----
|
|
305
|
+
// Models are stored per provider as `[models."provider/model"]`, and also
|
|
306
|
+
// mirrored into the provider's own pool at `[providers.NAME].models`. The
|
|
307
|
+
// display name defaults to the model id (edit config.toml to override).
|
|
308
|
+
export function modelKey(provider, modelId) { return `${provider}/${modelId}`; }
|
|
309
|
+
|
|
310
|
+
// Append a model under a provider (used by the "add model" flow and by the
|
|
311
|
+
// auto-fetch after adding a provider). Idempotent per key.
|
|
312
|
+
export function addModel(provider, modelId, opts = {}) {
|
|
313
|
+
const file = hncodeConfigFile();
|
|
314
|
+
let text = '';
|
|
315
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch {}
|
|
316
|
+
const key = modelKey(provider, modelId);
|
|
317
|
+
if (new RegExp(`^\\s*\\[models\\."${escapeRe(key)}"\\]`, 'm').test(text)) return file; // already there
|
|
318
|
+
const lines = [`[models."${key}"]`, `provider = "${provider}"`, `model = "${modelId}"`];
|
|
319
|
+
if (opts.display_name) lines.push(`display_name = "${String(opts.display_name).replace(/"/g, '\\"')}"`);
|
|
320
|
+
// Persist the context window discovered from /v1/models so resolveConfig can
|
|
321
|
+
// size the context bar per model (not a global 128k).
|
|
322
|
+
if (opts.contextLength) lines.push(`context_length = ${Number(opts.contextLength)}`);
|
|
323
|
+
if (opts.maxTokens) lines.push(`max_tokens = ${Number(opts.maxTokens)}`);
|
|
324
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
325
|
+
fs.appendFileSync(file, '\n' + lines.join('\n') + '\n', 'utf8');
|
|
326
|
+
return file;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function escapeRe(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
|
|
330
|
+
|
|
331
|
+
// Fetch the model list from a provider's API. The base URL is used as given and
|
|
332
|
+
// we append only `/models` (so `https://host/v1` → `https://host/v1/models`).
|
|
333
|
+
// Anthropic sends x-api-key + anthropic-version. Returns [{id, display}] or [].
|
|
334
|
+
export async function fetchModels({ baseUrl, apiKey, protocol }) {
|
|
335
|
+
const b = String(baseUrl || '').replace(/\/+$/, '');
|
|
336
|
+
if (!b) return [];
|
|
337
|
+
const url = `${b}/models`;
|
|
338
|
+
const headers = { 'content-type': 'application/json' };
|
|
339
|
+
if (protocol === 'anthropic') {
|
|
340
|
+
if (apiKey) headers['x-api-key'] = apiKey;
|
|
341
|
+
headers['anthropic-version'] = '2023-06-01';
|
|
342
|
+
} else if (apiKey) {
|
|
343
|
+
headers['authorization'] = `Bearer ${apiKey}`;
|
|
344
|
+
}
|
|
345
|
+
const ctrl = new AbortController();
|
|
346
|
+
const timer = setTimeout(() => ctrl.abort(), 15000);
|
|
347
|
+
try {
|
|
348
|
+
const res = await fetch(url, { headers, signal: ctrl.signal });
|
|
349
|
+
if (!res.ok) return [];
|
|
350
|
+
const json = await res.json();
|
|
351
|
+
// OpenAI: { data: [{ id }] } | Anthropic: { data: [{ id, display_name }] }
|
|
352
|
+
const arr = Array.isArray(json.data) ? json.data : (Array.isArray(json.models) ? json.models : []);
|
|
353
|
+
return arr.map((m) => ({
|
|
354
|
+
id: String(m.id || m.name || ''),
|
|
355
|
+
display: m.display_name || m.displayName || '',
|
|
356
|
+
// Capability hints (servers vary in what they expose).
|
|
357
|
+
contextLength: m.context_length || m.contextLength || undefined,
|
|
358
|
+
maxTokens: m.max_tokens || undefined, // NEW: per-model token limit from /v1/models
|
|
359
|
+
maxOutputTokens: m.max_output_tokens || m.maxOutputTokens || undefined,
|
|
360
|
+
ownedBy: m.owned_by || undefined,
|
|
361
|
+
// Reasoning support flags, when the server declares them.
|
|
362
|
+
reasoning: m.reasoning === true || m.supports_reasoning === true
|
|
363
|
+
|| Array.isArray(m.reasoning_efforts) || Array.isArray(m.supported_efforts)
|
|
364
|
+
|| undefined,
|
|
365
|
+
efforts: m.reasoning_efforts || m.supported_efforts || undefined,
|
|
366
|
+
})).filter((m) => m.id);
|
|
367
|
+
} catch { return []; }
|
|
368
|
+
finally { clearTimeout(timer); }
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ---- thinking effort capability ----
|
|
372
|
+
// The set of selectable efforts for a model. Honours, in order:
|
|
373
|
+
// 1. an explicit `efforts` / `reasoning_efforts` list on the model entry
|
|
374
|
+
// 2. an explicit `reasoning = false` (model cannot think)
|
|
375
|
+
// 3. a name heuristic for models known to expose graded effort
|
|
376
|
+
// Returns [] when the model has no thinking control (then /model shows no
|
|
377
|
+
// effort row and /effort reports it is unsupported).
|
|
378
|
+
// Selectable thinking efforts for a model, mirroring kimi's `segmentsFor`:
|
|
379
|
+
// * a model that declares `efforts` uses exactly those (plus a leading 'off'
|
|
380
|
+
// unless it is `always_thinking`, which cannot be turned off)
|
|
381
|
+
// * `reasoning = false` / `thinking = false` -> no control at all
|
|
382
|
+
// * otherwise fall back to id heuristics: graded OpenAI models get
|
|
383
|
+
// off/low/medium/high, Anthropic and everything else get off/on.
|
|
384
|
+
export function effortOptions(cfg, modelKeyName) {
|
|
385
|
+
const models = (cfg.raw && cfg.raw.models) || {};
|
|
386
|
+
const entry = models[modelKeyName || cfg.model] || {};
|
|
387
|
+
const declared = Array.isArray(entry.efforts) ? entry.efforts.filter(Boolean).slice() : [];
|
|
388
|
+
const alwaysOn = entry.always_thinking === true || entry.alwaysThinking === true;
|
|
389
|
+
if (declared.length) return alwaysOn ? declared : (declared[0] === 'off' ? declared : ['off', ...declared]);
|
|
390
|
+
if (alwaysOn) return ['on'];
|
|
391
|
+
if (entry.reasoning === false || entry.thinking === false) return [];
|
|
392
|
+
const id = String(entry.model || modelKeyName || cfg.innerModel || '').toLowerCase();
|
|
393
|
+
// OpenAI-style graded efforts.
|
|
394
|
+
if (/(^|\/)(gpt-5|o1|o3|o4)/.test(id) || /gpt-5|o1-|o3-|o4-/.test(id)) {
|
|
395
|
+
return ['off', 'low', 'medium', 'high'];
|
|
396
|
+
}
|
|
397
|
+
// Anthropic extended thinking is a budget, not a grade — expose on/off only.
|
|
398
|
+
if (cfg.protocol === 'anthropic') return ['off', 'on'];
|
|
399
|
+
// Default: thinking is on/off.
|
|
400
|
+
return ['off', 'on'];
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Map a chosen effort to the wire value for the active protocol.
|
|
404
|
+
export function effortWire(cfg, effort) {
|
|
405
|
+
if (!effort || effort === 'off') return null;
|
|
406
|
+
if (cfg.protocol === 'anthropic') {
|
|
407
|
+
const budget = { on: 8000, low: 2000, medium: 8000, high: 16000, max: 32000 }[effort] || 8000;
|
|
408
|
+
return { thinking: { type: 'enabled', budget_tokens: budget } };
|
|
409
|
+
}
|
|
410
|
+
// OpenAI-compatible: `reasoning_effort`. "on" is not a valid grade, so map it
|
|
411
|
+
// to "medium"; concrete grades pass through.
|
|
412
|
+
const grade = effort === 'on' ? 'medium' : effort;
|
|
413
|
+
return { reasoning_effort: grade };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function toTomlLine(k, v) {
|
|
417
|
+
if (typeof v === 'string') return `${k} = "${v.replace(/"/g, '\\"')}"`;
|
|
418
|
+
if (typeof v === 'boolean') return `${k} = ${v}`;
|
|
419
|
+
if (typeof v === 'number') return `${k} = ${v}`;
|
|
420
|
+
if (Array.isArray(v)) return `${k} = [ ${v.map((x) => `"${x}"`).join(', ')} ]`;
|
|
421
|
+
return `${k} = "${v}"`;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// The bare model id sent to the API. The config KEY is authoritative: it is
|
|
425
|
+
// always "<provider>/<model-id>", so the id is the key minus the provider
|
|
426
|
+
// prefix. The entry's `model` field is only consulted when the key has no
|
|
427
|
+
// provider prefix — older hncode versions wrote corrupt values there (e.g. a
|
|
428
|
+
// different model's id), so it must not override a well-formed key.
|
|
429
|
+
export function bareModelId(provider, key, entry) {
|
|
430
|
+
const e = entry || {};
|
|
431
|
+
const providerName = e.provider || provider || '';
|
|
432
|
+
const k = String(key || '');
|
|
433
|
+
// API 只需要 model id 本身(可能带子前缀,如 qoder/deepseek-flash),
|
|
434
|
+
// 不要 hncode 的 provider 前缀(traebuddy/)。所以剥掉 "<provider>/"。
|
|
435
|
+
if (providerName && k.startsWith(providerName + '/')) return k.slice(providerName.length + 1);
|
|
436
|
+
// key 没有 provider 前缀时,显式 model 字段优先;否则用 key 本身。
|
|
437
|
+
const fromField = typeof e.model === 'string' ? e.model : '';
|
|
438
|
+
return fromField || k;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Human-friendly model label for the status line.
|
|
442
|
+
// 1. an explicit `display_name` on the [models.*] entry wins (user's choice)
|
|
443
|
+
// 2. otherwise the bare model id, exactly as configured
|
|
444
|
+
export function modelLabel(cfg) {
|
|
445
|
+
const models = (cfg.raw && cfg.raw.models) || {};
|
|
446
|
+
const entry = models[cfg.model] || {};
|
|
447
|
+
if (entry.display_name) return entry.display_name;
|
|
448
|
+
return bareModelId(cfg.provider, cfg.model, entry) || cfg.innerModel || cfg.model || '';
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Remember the model the user last switched to, so a new hncode session starts
|
|
452
|
+
// on it. Stored as a top-level `model = "..."` in config.toml.
|
|
453
|
+
export function rememberModel(model) {
|
|
454
|
+
if (!model) return;
|
|
455
|
+
const file = hncodeConfigFile();
|
|
456
|
+
let text = '';
|
|
457
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch {}
|
|
458
|
+
const lines = text.split(/\r?\n/);
|
|
459
|
+
const out = [];
|
|
460
|
+
let replaced = false;
|
|
461
|
+
let seenTableHeader = false;
|
|
462
|
+
for (const ln of lines) {
|
|
463
|
+
const isModelLine = /^\s*model\s*=/.test(ln);
|
|
464
|
+
// A top-level `model = "..."` line lives before any `[table]` header.
|
|
465
|
+
// Once a table header appears, subsequent `model =` lines are table keys,
|
|
466
|
+
// not the active model.
|
|
467
|
+
if (isModelLine && !seenTableHeader && !replaced) {
|
|
468
|
+
out.push(`model = "${String(model).replace(/"/g, '\\"')}"`);
|
|
469
|
+
replaced = true;
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
out.push(ln);
|
|
473
|
+
if (/^\s*\[/.test(ln)) seenTableHeader = true;
|
|
474
|
+
}
|
|
475
|
+
if (!replaced) {
|
|
476
|
+
// No top-level model line: insert it before the first table header.
|
|
477
|
+
let at = out.findIndex((l) => /^\s*\[/.test(l));
|
|
478
|
+
if (at < 0) at = out.length;
|
|
479
|
+
out.splice(at, 0, `model = "${String(model).replace(/"/g, '\\"')}"`);
|
|
480
|
+
}
|
|
481
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
482
|
+
const joined = out.join('\n').replace(/^\n+/, '');
|
|
483
|
+
fs.writeFileSync(file, joined + (joined.endsWith('\n') ? '' : '\n'), 'utf8');
|
|
484
|
+
return file;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Non-secret snapshot for /settings display.
|
|
488
|
+
export function effectiveSnapshot(cfg) {
|
|
489
|
+
return {
|
|
490
|
+
model: cfg.model,
|
|
491
|
+
provider: cfg.provider,
|
|
492
|
+
baseUrl: cfg.baseUrl,
|
|
493
|
+
protocol: cfg.protocol,
|
|
494
|
+
endpoint: cfg.endpoint,
|
|
495
|
+
apiKeySet: !!cfg.apiKey,
|
|
496
|
+
reasoning: cfg.reasoning,
|
|
497
|
+
maxContextTokens: cfg.maxContextTokens,
|
|
498
|
+
maxOutputTokens: cfg.maxOutputTokens,
|
|
499
|
+
workspace: cfg.workspace,
|
|
500
|
+
allowExternal: cfg.allowExternal,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Re-resolve a provider's base URL / protocol / API key when the user switches
|
|
505
|
+
// providers at runtime (/provider, /model). The chosen provider's [providers.NAME]
|
|
506
|
+
// sub-table takes precedence (it's the point of switching); env vars set at boot
|
|
507
|
+
// are already baked into `cfg` and only remain in effect when the provider table
|
|
508
|
+
// does not override a given field.
|
|
509
|
+
export function resolveProvider(cfg, providerName, innerModel) {
|
|
510
|
+
const pr = (cfg.raw && cfg.raw.providers) || {};
|
|
511
|
+
const prov = pr[providerName] || {};
|
|
512
|
+
const baseUrl = (prov.base_url || prov.baseUrl || '') || cfg.baseUrl;
|
|
513
|
+
const apiKey = (prov.api_key || prov.apiKey || '') || cfg.apiKey;
|
|
514
|
+
const protocol = (prov.protocol) || cfg.protocol;
|
|
515
|
+
const inner = prov.model || (innerModel != null ? innerModel : cfg.innerModel);
|
|
516
|
+
const b = String(baseUrl || '').replace(/\/+$/, '');
|
|
517
|
+
const endpoint = protocol === 'anthropic' ? `${b}/messages` : `${b}/chat/completions`;
|
|
518
|
+
return { ...cfg, provider: providerName, innerModel: inner, baseUrl: b, endpoint, apiKey, protocol };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Switch model, optionally moving to a provider declared via [models.NAME].
|
|
522
|
+
// The returned cfg carries BOTH: `model` is the pool key (what the status line
|
|
523
|
+
// and rememberModel use) and `innerModel` is the bare id sent to the API.
|
|
524
|
+
export function resolveModelArg(cfg, modelName) {
|
|
525
|
+
if (!modelName) return cfg;
|
|
526
|
+
const models = (cfg.raw && cfg.raw.models) || {};
|
|
527
|
+
const entry = models[modelName] || {};
|
|
528
|
+
const provider = entry.provider || cfg.provider;
|
|
529
|
+
const inner = bareModelId(provider, modelName, entry);
|
|
530
|
+
return { ...resolveProvider(cfg, provider, inner), model: modelName };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export default { resolveConfig, saveConfig, effectiveSnapshot, resolveProvider, resolveModelArg, addProvider, removeProvider, addModel, fetchModels, fetchCatalog, modelKey, modelLabel, bareModelId, rememberModel, hncodeConfigFile };
|