smolcoder 0.4.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 +21 -0
- package/README.md +197 -0
- package/dist/agent.js +411 -0
- package/dist/context.js +337 -0
- package/dist/detect.js +199 -0
- package/dist/events.js +24 -0
- package/dist/index.js +680 -0
- package/dist/plan.js +91 -0
- package/dist/prompt.js +85 -0
- package/dist/providers/lmstudio.js +326 -0
- package/dist/providers/ollama.js +264 -0
- package/dist/providers/types.js +60 -0
- package/dist/sandbox.js +179 -0
- package/dist/tools/check.js +193 -0
- package/dist/tools/fs-tools.js +417 -0
- package/dist/tools/index.js +236 -0
- package/dist/tools/shell.js +168 -0
- package/dist/tools/tasks.js +128 -0
- package/dist/tui/editor.js +134 -0
- package/dist/tui/keys.js +145 -0
- package/dist/tui/tui.js +632 -0
- package/dist/ui.js +226 -0
- package/dist/util.js +72 -0
- package/dist/web/page.js +381 -0
- package/dist/web/webui.js +262 -0
- package/package.json +48 -0
package/dist/context.js
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Context budget management — the part local models actually live or die by.
|
|
3
|
+
//
|
|
4
|
+
// Fill gauge: every backend reports real prompt token usage per response; we
|
|
5
|
+
// anchor on that and only estimate the delta of new messages (chars-based). No
|
|
6
|
+
// homegrown tokenizer, works for any GGUF.
|
|
7
|
+
//
|
|
8
|
+
// Tiered compaction, cheap lever first:
|
|
9
|
+
// Tier 0 (free, continuous): stale-read eviction — the moment a file is
|
|
10
|
+
// overwritten, every earlier read of it is dead weight AND misleading.
|
|
11
|
+
// Tier 1 (free): evict old tool-result bodies and old reasoning traces —
|
|
12
|
+
// files can be re-read, so this is nearly lossless and usually recovers
|
|
13
|
+
// most of the window.
|
|
14
|
+
// Tier 2 (one model call): rebuild the transcript around a state note. The
|
|
15
|
+
// harness assembles the factual part deterministically (plan, files touched,
|
|
16
|
+
// commands run) and the model writes a structured progress summary with
|
|
17
|
+
// thinking OFF — local models summarize well, they just must not be allowed
|
|
18
|
+
// to reason for a minute about it.
|
|
19
|
+
//
|
|
20
|
+
// Guard rails learned the hard way:
|
|
21
|
+
// - compaction notes are FLAGGED so a later compaction strips them instead
|
|
22
|
+
// of stacking note-on-note (which made compaction stop shrinking anything)
|
|
23
|
+
// - the note always carries the CURRENT turn's request, not only the
|
|
24
|
+
// session's first one
|
|
25
|
+
// - when the irreducible floor (system prompt + tools + protected tail)
|
|
26
|
+
// alone exceeds the threshold, we stop trying instead of thrashing a
|
|
27
|
+
// futile summarizer call before every request
|
|
28
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
+
exports.ContextManager = void 0;
|
|
30
|
+
exports.renderForDigest = renderForDigest;
|
|
31
|
+
const types_1 = require("./providers/types");
|
|
32
|
+
const util_1 = require("./util");
|
|
33
|
+
const MSG_OVERHEAD_TOKENS = 8;
|
|
34
|
+
const EVICT_KEEP_RECENT = 6; // never evict tool results in the last N messages
|
|
35
|
+
const EVICT_STUB = "[old output removed to save space — run the tool again if you need it]";
|
|
36
|
+
const STALE_READ_STUB = "[this read is out of date — the file was rewritten afterwards. Call read_file again if you need its current content.]";
|
|
37
|
+
const STALE_READ_MIN_CHARS = 1500; // small reads are cheaper to keep than to re-prefill around
|
|
38
|
+
/** Tool-call args for a tool-result message (the call lives on the preceding
|
|
39
|
+
* assistant message). */
|
|
40
|
+
function callFor(messages, toolMsgIndex) {
|
|
41
|
+
const id = messages[toolMsgIndex].toolCallId;
|
|
42
|
+
if (!id)
|
|
43
|
+
return null;
|
|
44
|
+
for (let i = toolMsgIndex - 1; i >= 0; i--) {
|
|
45
|
+
const m = messages[i];
|
|
46
|
+
if (m.role !== "assistant" || !m.toolCalls)
|
|
47
|
+
continue;
|
|
48
|
+
const tc = m.toolCalls.find((t) => t.id === id);
|
|
49
|
+
if (tc)
|
|
50
|
+
return { name: tc.name, args: tc.args };
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
class ContextManager {
|
|
55
|
+
window;
|
|
56
|
+
reserve;
|
|
57
|
+
lastPromptTokens = 0;
|
|
58
|
+
lastCompletionTokens = 0;
|
|
59
|
+
anchorIndex = 0; // messages.length at the time usage was reported
|
|
60
|
+
floorWarned = false;
|
|
61
|
+
constructor(window, reserve) {
|
|
62
|
+
this.window = window;
|
|
63
|
+
this.reserve = reserve;
|
|
64
|
+
}
|
|
65
|
+
/** Model switches mid-session change the window we budget against. */
|
|
66
|
+
setWindow(window, reserve) {
|
|
67
|
+
this.window = window;
|
|
68
|
+
if (reserve !== undefined)
|
|
69
|
+
this.reserve = reserve;
|
|
70
|
+
this.resetAnchor();
|
|
71
|
+
}
|
|
72
|
+
/** Invariant: lastPromptTokens + lastCompletionTokens cover exactly the
|
|
73
|
+
* first `anchorIndex` messages of the transcript at record time. */
|
|
74
|
+
recordUsage(promptTokens, completionTokens, messageCount) {
|
|
75
|
+
if (typeof promptTokens === "number" && promptTokens > 0) {
|
|
76
|
+
this.lastPromptTokens = promptTokens;
|
|
77
|
+
this.lastCompletionTokens = completionTokens ?? 0;
|
|
78
|
+
this.anchorIndex = messageCount;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Drop the usage anchor (transcript replaced/cleared behind it). */
|
|
82
|
+
resetAnchor() {
|
|
83
|
+
this.lastPromptTokens = 0;
|
|
84
|
+
this.lastCompletionTokens = 0;
|
|
85
|
+
this.anchorIndex = 0;
|
|
86
|
+
this.floorWarned = false;
|
|
87
|
+
}
|
|
88
|
+
estimateMessages(messages) {
|
|
89
|
+
// Reasoning traces before the current user turn are not sent to the
|
|
90
|
+
// backend (see providers), so they must not count either.
|
|
91
|
+
const thinkingFrom = (0, types_1.lastUserIndex)(messages);
|
|
92
|
+
let total = 0;
|
|
93
|
+
for (let i = 0; i < messages.length; i++) {
|
|
94
|
+
const m = messages[i];
|
|
95
|
+
total += (0, util_1.estimateTokens)(m.content ?? "") + MSG_OVERHEAD_TOKENS;
|
|
96
|
+
if (m.thinking && i > thinkingFrom)
|
|
97
|
+
total += (0, util_1.estimateTokens)(m.thinking);
|
|
98
|
+
if (m.toolCalls) {
|
|
99
|
+
for (const tc of m.toolCalls) {
|
|
100
|
+
total += (0, util_1.estimateTokens)(tc.name + JSON.stringify(tc.args)) + MSG_OVERHEAD_TOKENS;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return total;
|
|
105
|
+
}
|
|
106
|
+
estimateTools(tools) {
|
|
107
|
+
return (0, util_1.estimateTokens)(JSON.stringify(tools));
|
|
108
|
+
}
|
|
109
|
+
/** Best estimate of the next request's prompt size in tokens. */
|
|
110
|
+
estimatePrompt(messages, tools) {
|
|
111
|
+
const charBased = this.estimateMessages(messages) + this.estimateTools(tools);
|
|
112
|
+
if (this.lastPromptTokens > 0 && this.anchorIndex <= messages.length) {
|
|
113
|
+
const newMsgs = messages.slice(this.anchorIndex);
|
|
114
|
+
const anchored = this.lastPromptTokens + this.lastCompletionTokens + this.estimateMessages(newMsgs);
|
|
115
|
+
return Math.max(charBased, anchored);
|
|
116
|
+
}
|
|
117
|
+
return charBased;
|
|
118
|
+
}
|
|
119
|
+
usableWindow() {
|
|
120
|
+
return this.window - this.reserve;
|
|
121
|
+
}
|
|
122
|
+
fillPercent(messages, tools) {
|
|
123
|
+
return Math.min(100, Math.round((this.estimatePrompt(messages, tools) / this.window) * 100));
|
|
124
|
+
}
|
|
125
|
+
needsAttention(messages, tools) {
|
|
126
|
+
if (this.estimatePrompt(messages, tools) <= 0.8 * this.usableWindow()) {
|
|
127
|
+
this.floorWarned = false; // healthy again — re-arm the floor warning
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
// Once we've established the transcript cannot shrink further, stop
|
|
131
|
+
// triggering a futile compaction before every request.
|
|
132
|
+
return !this.floorWarned;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Tier 0: a file was just completely rewritten — every earlier read_file
|
|
136
|
+
* result for that path is now wrong. Replace the big ones with a stub so
|
|
137
|
+
* they neither cost context nor mislead the next edit. Returns how many
|
|
138
|
+
* results were stubbed.
|
|
139
|
+
*/
|
|
140
|
+
evictStaleReads(messages, filePath) {
|
|
141
|
+
const norm = (p) => p.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
142
|
+
const target = norm(filePath);
|
|
143
|
+
let n = 0;
|
|
144
|
+
// Skip the most recent message: it is the write's own result.
|
|
145
|
+
for (let i = 1; i < messages.length - 1; i++) {
|
|
146
|
+
const m = messages[i];
|
|
147
|
+
if (m.role !== "tool" || m.evicted || m.content.length < STALE_READ_MIN_CHARS)
|
|
148
|
+
continue;
|
|
149
|
+
const call = callFor(messages, i);
|
|
150
|
+
if (!call || call.name !== "read_file")
|
|
151
|
+
continue;
|
|
152
|
+
if (norm(String(call.args?.path ?? "")) !== target)
|
|
153
|
+
continue;
|
|
154
|
+
m.content = STALE_READ_STUB;
|
|
155
|
+
m.evicted = true;
|
|
156
|
+
n++;
|
|
157
|
+
}
|
|
158
|
+
if (n) {
|
|
159
|
+
// The transcript shrank behind the usage anchor.
|
|
160
|
+
this.anchorIndex = 0;
|
|
161
|
+
this.lastPromptTokens = 0;
|
|
162
|
+
}
|
|
163
|
+
return n;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Bring the transcript back under budget. Mutates and/or replaces `messages`;
|
|
167
|
+
* returns the (possibly new) array plus a report for the UI.
|
|
168
|
+
*/
|
|
169
|
+
async manage(messages, tools, provider, state) {
|
|
170
|
+
const before = this.estimatePrompt(messages, tools);
|
|
171
|
+
if (before <= 0.8 * this.usableWindow()) {
|
|
172
|
+
return { messages, report: { action: "none", before, after: before } };
|
|
173
|
+
}
|
|
174
|
+
// Tier 1a: reasoning traces of finished turns are never sent again —
|
|
175
|
+
// drop them for real so they stop costing memory and estimate.
|
|
176
|
+
const thinkingFrom = (0, types_1.lastUserIndex)(messages);
|
|
177
|
+
for (let i = 1; i < thinkingFrom; i++) {
|
|
178
|
+
if (messages[i].role === "assistant" && messages[i].thinking)
|
|
179
|
+
messages[i].thinking = undefined;
|
|
180
|
+
}
|
|
181
|
+
// Tier 1b: evict old tool-result bodies, oldest first.
|
|
182
|
+
const evictBoundary = Math.max(1, messages.length - EVICT_KEEP_RECENT);
|
|
183
|
+
for (let i = 1; i < evictBoundary; i++) {
|
|
184
|
+
const m = messages[i];
|
|
185
|
+
if (m.role === "tool" && !m.evicted && m.content.length > 200) {
|
|
186
|
+
m.content = EVICT_STUB;
|
|
187
|
+
m.evicted = true;
|
|
188
|
+
// invalidate the usage anchor — the transcript shrank behind it
|
|
189
|
+
this.anchorIndex = 0;
|
|
190
|
+
this.lastPromptTokens = 0;
|
|
191
|
+
if (this.estimatePrompt(messages, tools) <= 0.6 * this.usableWindow())
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
let after = this.estimatePrompt(messages, tools);
|
|
196
|
+
if (after <= 0.8 * this.usableWindow()) {
|
|
197
|
+
return { messages, report: { action: "evicted", before, after } };
|
|
198
|
+
}
|
|
199
|
+
// Tier 2: full compaction around a state note.
|
|
200
|
+
const compacted = await this.compact(messages, provider, state);
|
|
201
|
+
this.anchorIndex = 0;
|
|
202
|
+
this.lastPromptTokens = 0;
|
|
203
|
+
after = this.estimatePrompt(compacted, tools);
|
|
204
|
+
if (after > 0.8 * this.usableWindow()) {
|
|
205
|
+
// Irreducible floor: the window simply cannot hold what must stay
|
|
206
|
+
// (system prompt + AGENTS.md + tool schemas + the working tail).
|
|
207
|
+
// Continue anyway, but stop re-compacting on every request.
|
|
208
|
+
this.floorWarned = true;
|
|
209
|
+
return { messages: compacted, report: { action: "floor", before, after } };
|
|
210
|
+
}
|
|
211
|
+
return { messages: compacted, report: { action: "compacted", before, after } };
|
|
212
|
+
}
|
|
213
|
+
async compact(allMessages, provider, state) {
|
|
214
|
+
const system = allMessages[0];
|
|
215
|
+
// Strip prior compaction notes — their content is regenerated fresh below.
|
|
216
|
+
// Without this, notes accrete (each new note keeps the old one in its
|
|
217
|
+
// tail) and compaction stops shrinking the transcript at all.
|
|
218
|
+
const messages = [system, ...allMessages.slice(1).filter((m) => !m.compactNote)];
|
|
219
|
+
// Deterministic part of the state note — the harness knows these facts.
|
|
220
|
+
// The plan goes first: it is the model's map of the task.
|
|
221
|
+
const facts = [];
|
|
222
|
+
if (state.planLine)
|
|
223
|
+
facts.push(state.planLine);
|
|
224
|
+
if (state.filesTouched.size) {
|
|
225
|
+
facts.push(`Files created/modified so far: ${[...state.filesTouched].slice(-30).join(", ")}`);
|
|
226
|
+
}
|
|
227
|
+
if (state.commandsRun.length) {
|
|
228
|
+
facts.push(`Commands run so far: ${state.commandsRun.slice(-15).join("; ")}`);
|
|
229
|
+
}
|
|
230
|
+
// Model-written progress summary — structured, thinking off, short cap.
|
|
231
|
+
// Skipped entirely on very small windows: the summarize call itself must
|
|
232
|
+
// fit, and on Ollama an oversized prompt is silently front-truncated
|
|
233
|
+
// (losing the instructions), so facts-only is the safe degradation. If the
|
|
234
|
+
// call fails, facts alone carry the note.
|
|
235
|
+
let narrative = "";
|
|
236
|
+
const digestBudgetChars = Math.min(60000, Math.max(0, (this.usableWindow() - 1200) * 3));
|
|
237
|
+
if (digestBudgetChars >= 3000) {
|
|
238
|
+
try {
|
|
239
|
+
const transcript = renderForDigest(messages.slice(1), digestBudgetChars);
|
|
240
|
+
const res = await provider.chat([
|
|
241
|
+
{
|
|
242
|
+
role: "system",
|
|
243
|
+
content: "You write hand-over notes for a coding agent whose conversation is about to be cleared. Be concrete and factual; never invent. Reply with only the notes.",
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
role: "user",
|
|
247
|
+
content: `Write hand-over notes for this session under exactly these headings:\n` +
|
|
248
|
+
`Task: the user's goal in one sentence.\n` +
|
|
249
|
+
`Done: what is finished and known to work (files, features).\n` +
|
|
250
|
+
`In progress: what was being worked on when the log ends, and its current state.\n` +
|
|
251
|
+
`Next: the next concrete step.\n` +
|
|
252
|
+
`Notes: key decisions, gotchas, exact names/APIs/values the agent must not forget, and any unresolved errors.\n` +
|
|
253
|
+
`Keep it under 250 words. Prefer file names, function names and exact error text over prose.\n\n` +
|
|
254
|
+
(state.planLine ? `Current plan:\n${state.planLine}\n\n` : "") +
|
|
255
|
+
`Session log (oldest first, long outputs shortened):\n${transcript}`,
|
|
256
|
+
},
|
|
257
|
+
], [], { effortOverride: "off", maxTokens: 700 });
|
|
258
|
+
narrative = res.content.trim();
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
narrative = "";
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// Keep a clean tail. Preferred cut: the most recent plain user message.
|
|
265
|
+
// Mid-turn there often is none nearby — then keep the last COMPLETE
|
|
266
|
+
// assistant-toolcall + tool-results group instead of dropping everything,
|
|
267
|
+
// so the model retains the material it just fetched for its next action.
|
|
268
|
+
let keepFrom = messages.length;
|
|
269
|
+
for (let i = messages.length - 1; i >= Math.max(1, messages.length - 8); i--) {
|
|
270
|
+
if (messages[i].role === "user" && !messages[i].compactNote)
|
|
271
|
+
keepFrom = i;
|
|
272
|
+
}
|
|
273
|
+
if (keepFrom === messages.length) {
|
|
274
|
+
for (let i = messages.length - 1; i >= 1; i--) {
|
|
275
|
+
const m = messages[i];
|
|
276
|
+
if (m.role === "assistant" && m.toolCalls?.length) {
|
|
277
|
+
const allAnswered = m.toolCalls.every((tc) => messages.slice(i + 1).some((t) => t.role === "tool" && t.toolCallId === tc.id));
|
|
278
|
+
if (allAnswered)
|
|
279
|
+
keepFrom = i;
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
if (m.role === "assistant") {
|
|
283
|
+
keepFrom = i;
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const tail = keepFrom < messages.length ? messages.slice(keepFrom) : [];
|
|
289
|
+
// The tail's own reasoning is history now; the model does not need to
|
|
290
|
+
// re-read its old thoughts, and Ollama would replay them.
|
|
291
|
+
for (const m of tail)
|
|
292
|
+
if (m.role === "assistant")
|
|
293
|
+
m.thinking = undefined;
|
|
294
|
+
const requestLines = state.currentRequest && state.currentRequest !== state.originalRequest
|
|
295
|
+
? `Original request: ${(0, util_1.truncateEnd)(state.originalRequest, 600)}\nCurrent request (what you are working on NOW): ${(0, util_1.truncateEnd)(state.currentRequest, 1000)}\n`
|
|
296
|
+
: `Original request: ${(0, util_1.truncateEnd)(state.originalRequest, 1000)}\n`;
|
|
297
|
+
const note = `[The conversation so far was compacted to save context. Continue the task from these notes — do not start over, and do not redo finished steps.]\n` +
|
|
298
|
+
requestLines +
|
|
299
|
+
(facts.length ? facts.join("\n") + "\n" : "") +
|
|
300
|
+
(narrative ? `\nHand-over notes:\n${narrative}` : "");
|
|
301
|
+
return [system, { role: "user", content: note, compactNote: true }, ...tail];
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
exports.ContextManager = ContextManager;
|
|
305
|
+
/** Transcript rendering for the summarizer: recent messages get more room
|
|
306
|
+
* than old ones (the end of the log is where the live state is), tool
|
|
307
|
+
* results are shortened, reasoning traces are dropped entirely. Exported for
|
|
308
|
+
* tests. */
|
|
309
|
+
function renderForDigest(messages, budgetChars) {
|
|
310
|
+
const n = messages.length;
|
|
311
|
+
const lines = [];
|
|
312
|
+
for (let i = 0; i < n; i++) {
|
|
313
|
+
const m = messages[i];
|
|
314
|
+
const recent = i >= n - 12;
|
|
315
|
+
const cap = m.role === "tool" ? (recent ? 700 : 200) : recent ? 1500 : 400;
|
|
316
|
+
const tools = m.toolCalls
|
|
317
|
+
?.map((t) => {
|
|
318
|
+
const a = { ...t.args };
|
|
319
|
+
if (typeof a.content === "string")
|
|
320
|
+
a.content = `<${a.content.length} chars>`;
|
|
321
|
+
if (typeof a.new_text === "string")
|
|
322
|
+
a.new_text = (0, util_1.truncateEnd)(a.new_text, 120);
|
|
323
|
+
if (typeof a.old_text === "string")
|
|
324
|
+
a.old_text = (0, util_1.truncateEnd)(a.old_text, 80);
|
|
325
|
+
return `${t.name}(${(0, util_1.truncateEnd)(JSON.stringify(a), 200)})`;
|
|
326
|
+
})
|
|
327
|
+
.join(", ");
|
|
328
|
+
const body = (0, util_1.truncateEnd)(m.content ?? "", cap);
|
|
329
|
+
lines.push(`${m.role.toUpperCase()}: ${body}${tools ? ` [called: ${tools}]` : ""}`);
|
|
330
|
+
}
|
|
331
|
+
let out = lines.join("\n");
|
|
332
|
+
if (out.length > budgetChars) {
|
|
333
|
+
// Keep the END of the log — that is where the current state lives.
|
|
334
|
+
out = "[earlier log omitted]\n" + out.slice(out.length - budgetChars);
|
|
335
|
+
}
|
|
336
|
+
return out;
|
|
337
|
+
}
|
package/dist/detect.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Zero-config backend detection. Probe the standard Ollama and LM Studio
|
|
3
|
+
// endpoints with short timeouts, merge whatever answers. No configuration.
|
|
4
|
+
//
|
|
5
|
+
// The two backends are asymmetric on context windows:
|
|
6
|
+
// - Ollama: WE choose the window (num_ctx is a per-request option). Read the
|
|
7
|
+
// model's true maximum from /api/show and set num_ctx explicitly, because
|
|
8
|
+
// Ollama's defaults vary by version and silently truncate the prompt.
|
|
9
|
+
// - LM Studio: the window is fixed when the model is loaded in LM Studio's
|
|
10
|
+
// UI. We READ it from /api/v1/models (or the older /api/v0) and adapt.
|
|
11
|
+
// The same endpoint tells us which reasoning levels the model supports
|
|
12
|
+
// and which one it defaults to — that default is often the MAXIMUM.
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.detectOllamaModels = detectOllamaModels;
|
|
15
|
+
exports.parseLmStudioV1 = parseLmStudioV1;
|
|
16
|
+
exports.detectLmStudioModels = detectLmStudioModels;
|
|
17
|
+
exports.detectAll = detectAll;
|
|
18
|
+
exports.resolveContextWindow = resolveContextWindow;
|
|
19
|
+
const util_1 = require("./util");
|
|
20
|
+
function ollamaBaseUrl() {
|
|
21
|
+
const env = process.env.OLLAMA_HOST;
|
|
22
|
+
let base;
|
|
23
|
+
if (!env)
|
|
24
|
+
base = "http://127.0.0.1:11434";
|
|
25
|
+
else if (env.startsWith("http://") || env.startsWith("https://"))
|
|
26
|
+
base = env.replace(/\/$/, "");
|
|
27
|
+
else
|
|
28
|
+
base = `http://${env.replace(/\/$/, "")}`;
|
|
29
|
+
// OLLAMA_HOST=0.0.0.0 is the documented way to expose the SERVER on the LAN,
|
|
30
|
+
// but as a CLIENT connect address 0.0.0.0/:: fails on Windows (WSAEADDRNOTAVAIL)
|
|
31
|
+
// and would make detection silently return no models. Rewrite to loopback.
|
|
32
|
+
return base.replace(/^(https?:\/\/)(0\.0\.0\.0|\[::\]|::)(?=[:/]|$)/, "$1127.0.0.1");
|
|
33
|
+
}
|
|
34
|
+
const DEFAULT_OLLAMA_CTX_CAP = 32768; // avoid surprise VRAM blowups on huge-window models
|
|
35
|
+
const LMSTUDIO_JIT_GUESS = 4096; // LM Studio's usual default when a model is JIT-loaded
|
|
36
|
+
const LMSTUDIO_BASE = "http://127.0.0.1:1234";
|
|
37
|
+
async function detectOllamaModels() {
|
|
38
|
+
const base = ollamaBaseUrl();
|
|
39
|
+
const data = await (0, util_1.tryFetchJson)(`${base}/api/tags`);
|
|
40
|
+
if (!data || !Array.isArray(data.models))
|
|
41
|
+
return [];
|
|
42
|
+
return data.models.map((m) => ({
|
|
43
|
+
id: m.name,
|
|
44
|
+
backend: "ollama",
|
|
45
|
+
baseUrl: base,
|
|
46
|
+
contextWindow: 0, // resolved lazily via /api/show when the model is chosen
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
const NOT_LOADED_NOTE = "not loaded yet — LM Studio will load it on first use, likely at a small default context. For longer sessions, load it in LM Studio with a bigger context first.";
|
|
50
|
+
/** Exported for tests: parse LM Studio's /api/v1/models listing. */
|
|
51
|
+
function parseLmStudioV1(data) {
|
|
52
|
+
if (!data || !Array.isArray(data.models))
|
|
53
|
+
return null;
|
|
54
|
+
return data.models
|
|
55
|
+
.filter((m) => m.type === "llm" || m.type === "vlm" || m.type === undefined)
|
|
56
|
+
.map((m) => {
|
|
57
|
+
const inst = Array.isArray(m.loaded_instances) ? m.loaded_instances[0] : undefined;
|
|
58
|
+
const loaded = !!inst;
|
|
59
|
+
const max = typeof m.max_context_length === "number" ? m.max_context_length : undefined;
|
|
60
|
+
const loadedCtx = typeof inst?.config?.context_length === "number" ? inst.config.context_length : undefined;
|
|
61
|
+
const r = m.capabilities?.reasoning;
|
|
62
|
+
const reasoning = r && Array.isArray(r.allowed_options)
|
|
63
|
+
? { allowed: r.allowed_options.map(String), default: r.default ? String(r.default) : undefined }
|
|
64
|
+
: undefined;
|
|
65
|
+
return {
|
|
66
|
+
id: String(inst?.id ?? m.key),
|
|
67
|
+
backend: "lmstudio",
|
|
68
|
+
baseUrl: LMSTUDIO_BASE,
|
|
69
|
+
contextWindow: loaded && loadedCtx ? loadedCtx : Math.min(max ?? LMSTUDIO_JIT_GUESS, LMSTUDIO_JIT_GUESS),
|
|
70
|
+
maxContext: max,
|
|
71
|
+
loaded,
|
|
72
|
+
reasoning,
|
|
73
|
+
note: loaded && loadedCtx ? undefined : NOT_LOADED_NOTE,
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
async function detectLmStudioModels() {
|
|
78
|
+
const base = LMSTUDIO_BASE;
|
|
79
|
+
// Newest listing first: it carries the loaded context, and the reasoning
|
|
80
|
+
// levels the model supports (needed to make effort mean what it says).
|
|
81
|
+
const v1 = parseLmStudioV1(await (0, util_1.tryFetchJson)(`${base}/api/v1/models`));
|
|
82
|
+
if (v1)
|
|
83
|
+
return v1;
|
|
84
|
+
const data = await (0, util_1.tryFetchJson)(`${base}/api/v0/models`);
|
|
85
|
+
if (data && Array.isArray(data.data)) {
|
|
86
|
+
return data.data
|
|
87
|
+
.filter((m) => m.type === "llm" || m.type === "vlm" || m.type === undefined)
|
|
88
|
+
.map((m) => {
|
|
89
|
+
const loaded = m.state === "loaded";
|
|
90
|
+
const max = typeof m.max_context_length === "number" ? m.max_context_length : undefined;
|
|
91
|
+
const loadedCtx = typeof m.loaded_context_length === "number" ? m.loaded_context_length : undefined;
|
|
92
|
+
let contextWindow;
|
|
93
|
+
let note;
|
|
94
|
+
if (loaded && loadedCtx) {
|
|
95
|
+
contextWindow = loadedCtx;
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
contextWindow = Math.min(max ?? LMSTUDIO_JIT_GUESS, LMSTUDIO_JIT_GUESS);
|
|
99
|
+
note = NOT_LOADED_NOTE;
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
id: m.id,
|
|
103
|
+
backend: "lmstudio",
|
|
104
|
+
baseUrl: base,
|
|
105
|
+
contextWindow,
|
|
106
|
+
maxContext: max,
|
|
107
|
+
loaded,
|
|
108
|
+
note,
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
// Older LM Studio builds: fall back to the OpenAI-compat listing (no context info).
|
|
113
|
+
const v1compat = await (0, util_1.tryFetchJson)(`${base}/v1/models`);
|
|
114
|
+
if (v1compat && Array.isArray(v1compat.data)) {
|
|
115
|
+
return v1compat.data
|
|
116
|
+
.filter((m) => !String(m.id).includes("embed"))
|
|
117
|
+
.map((m) => ({
|
|
118
|
+
id: m.id,
|
|
119
|
+
backend: "lmstudio",
|
|
120
|
+
baseUrl: base,
|
|
121
|
+
contextWindow: LMSTUDIO_JIT_GUESS,
|
|
122
|
+
note: "context window unknown (older LM Studio) — assuming 4096 to be safe.",
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
async function detectAll() {
|
|
128
|
+
const [ollama, lmstudio] = await Promise.all([detectOllamaModels(), detectLmStudioModels()]);
|
|
129
|
+
return [...ollama, ...lmstudio];
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Resolve the context window we will actually budget against for a chosen model.
|
|
133
|
+
*
|
|
134
|
+
* Ollama: by default we respect the SERVER's configured context (the Ollama
|
|
135
|
+
* app's Context Length setting / OLLAMA_CONTEXT_LENGTH) and never send
|
|
136
|
+
* num_ctx. To learn the effective value we preload the model and read
|
|
137
|
+
* context_length from /api/ps. Only two cases send an explicit num_ctx: a
|
|
138
|
+
* --ctx override, or an old Ollama whose /api/ps doesn't report context (where
|
|
139
|
+
* the tiny silent default is the classic footgun).
|
|
140
|
+
*/
|
|
141
|
+
async function resolveContextWindow(model, ctxOverride) {
|
|
142
|
+
if (model.backend === "ollama") {
|
|
143
|
+
const info = await (0, util_1.tryFetchJson)(`${model.baseUrl}/api/show`, {
|
|
144
|
+
method: "POST",
|
|
145
|
+
headers: { "content-type": "application/json" },
|
|
146
|
+
body: JSON.stringify({ model: model.id }),
|
|
147
|
+
});
|
|
148
|
+
let max;
|
|
149
|
+
const mi = info?.model_info;
|
|
150
|
+
if (mi && typeof mi === "object") {
|
|
151
|
+
for (const key of Object.keys(mi)) {
|
|
152
|
+
if (key.endsWith(".context_length") && typeof mi[key] === "number") {
|
|
153
|
+
max = mi[key];
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (ctxOverride) {
|
|
159
|
+
const window = Math.min(ctxOverride, max ?? ctxOverride);
|
|
160
|
+
return { ...model, maxContext: max, contextWindow: window, numCtx: window };
|
|
161
|
+
}
|
|
162
|
+
// Preload the model (documented no-op chat), then read the effective
|
|
163
|
+
// context the server actually allocated.
|
|
164
|
+
await (0, util_1.tryFetchJson)(`${model.baseUrl}/api/chat`, {
|
|
165
|
+
method: "POST",
|
|
166
|
+
headers: { "content-type": "application/json" },
|
|
167
|
+
body: JSON.stringify({ model: model.id, messages: [] }),
|
|
168
|
+
}, 180_000);
|
|
169
|
+
const ps = await (0, util_1.tryFetchJson)(`${model.baseUrl}/api/ps`, undefined, 3000);
|
|
170
|
+
// Match the CHOSEN model only — never fall back to models[0]. If our
|
|
171
|
+
// preload failed (e.g. too big for VRAM) but a different model is still
|
|
172
|
+
// resident, models[0] would anchor the budget to the wrong window.
|
|
173
|
+
const entry = ps?.models?.find((m) => m.name === model.id || m.model === model.id);
|
|
174
|
+
if (typeof entry?.context_length === "number" && entry.context_length > 0) {
|
|
175
|
+
return {
|
|
176
|
+
...model,
|
|
177
|
+
maxContext: max,
|
|
178
|
+
contextWindow: entry.context_length,
|
|
179
|
+
numCtx: undefined, // respect the server's configuration
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
// Older Ollama: no visibility into the server default, which is tiny and
|
|
183
|
+
// silently truncates — set num_ctx explicitly ourselves.
|
|
184
|
+
const window = Math.min(max ?? DEFAULT_OLLAMA_CTX_CAP, DEFAULT_OLLAMA_CTX_CAP);
|
|
185
|
+
return {
|
|
186
|
+
...model,
|
|
187
|
+
maxContext: max,
|
|
188
|
+
contextWindow: window,
|
|
189
|
+
numCtx: window,
|
|
190
|
+
note: `older Ollama — setting the context to ${window.toLocaleString()} explicitly (adjust with --ctx).`,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
// LM Studio: window was read during detection; an override can only shrink our budget
|
|
194
|
+
// (we cannot change what LM Studio allocated).
|
|
195
|
+
if (ctxOverride && ctxOverride < model.contextWindow) {
|
|
196
|
+
return { ...model, contextWindow: ctxOverride };
|
|
197
|
+
}
|
|
198
|
+
return model;
|
|
199
|
+
}
|
package/dist/events.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Internal lifecycle event bus. Handlers run sequentially and may be async,
|
|
3
|
+
// so a pre_request handler can finish compaction before the request goes out.
|
|
4
|
+
// Not user-configurable in v1 by design — this is the spine that compaction,
|
|
5
|
+
// the context meter, and logging hang off. User-facing hooks can come later.
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.EventBus = void 0;
|
|
8
|
+
class EventBus {
|
|
9
|
+
handlers = new Map();
|
|
10
|
+
on(event, handler) {
|
|
11
|
+
const list = this.handlers.get(event) ?? [];
|
|
12
|
+
list.push(handler);
|
|
13
|
+
this.handlers.set(event, list);
|
|
14
|
+
}
|
|
15
|
+
async emit(event, payload) {
|
|
16
|
+
const list = this.handlers.get(event);
|
|
17
|
+
if (!list)
|
|
18
|
+
return;
|
|
19
|
+
for (const h of list) {
|
|
20
|
+
await h(payload);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
exports.EventBus = EventBus;
|