opencode-localhost 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.
@@ -0,0 +1,160 @@
1
+ import fs from "fs/promises"
2
+ import path from "path"
3
+ import * as Ini from "../../shared/ini.ts"
4
+ import { configDir } from "../../shared/paths.ts"
5
+ import type { LocalModel } from "./discover.ts"
6
+
7
+ /**
8
+ * models.ini — llama.cpp's file, not ours. Handed to
9
+ * `llama-server --models-preset` verbatim; llama.cpp parses it, we only append.
10
+ *
11
+ * The contract with the user: we add a section when a new .gguf appears and
12
+ * never modify or remove one afterwards. Everything in here is a llama-server
13
+ * flag, so any flag llama.cpp accepts works whether or not we know about it.
14
+ */
15
+
16
+ export const FILE = path.join(configDir("llamacpp"), "models.ini")
17
+
18
+ const HEADER = `version = 1
19
+
20
+ # Per-model llama-server settings. YOURS TO EDIT.
21
+ # New models get a section appended; existing sections are never touched.
22
+ # Any llama-server flag works as a key, without the leading dashes:
23
+ #
24
+ # ctx-size = 32768 context window, in tokens
25
+ # gpu-layers = 99 layers on GPU (99 = all)
26
+ # tensor-split = 0.6,0.4 split across multiple GPUs
27
+ # cache-type-k = q8_0 KV cache quantisation: f16 | q8_0 | q4_0
28
+ # flash-attn = on
29
+ # temp = 0.7 sampling; sent per-request, no reload needed
30
+ #
31
+ # Launch flags apply on the next model load. Sampling applies immediately.`
32
+
33
+ /**
34
+ * Conservative on purpose: these must not OOM on a machine we know nothing
35
+ * about. 32k context with a quantised KV cache fits alongside most models on
36
+ * a single mid-range card. Tune upward per model; that is what the file is for.
37
+ */
38
+ const DEFAULTS: Record<string, string> = {
39
+ "ctx-size": "32768",
40
+ "gpu-layers": "99",
41
+ "flash-attn": "on",
42
+ "cache-type-k": "q8_0",
43
+ "cache-type-v": "q8_0",
44
+ jinja: "true",
45
+ }
46
+
47
+ export type ModelSettings = {
48
+ context: number
49
+ sampling: Record<string, number>
50
+ }
51
+
52
+ const SAMPLING_KEYS = ["temp", "top-p", "top-k", "min-p", "repeat-penalty", "presence-penalty"] as const
53
+
54
+ /** llama.cpp flag names -> OpenAI request body fields. */
55
+ const SAMPLING_WIRE: Record<string, string> = {
56
+ temp: "temperature",
57
+ "top-p": "top_p",
58
+ "top-k": "top_k",
59
+ "min-p": "min_p",
60
+ "repeat-penalty": "repetition_penalty",
61
+ "presence-penalty": "presence_penalty",
62
+ }
63
+
64
+ function section(model: LocalModel): string {
65
+ const lines = [`[${model.id}]`, `model = ${model.file}`]
66
+ if (model.mmproj) lines.push(`mmproj = ${model.mmproj}`)
67
+ for (const [key, value] of Object.entries(DEFAULTS)) lines.push(`${key} = ${value}`)
68
+ return lines.join("\n")
69
+ }
70
+
71
+ export type SyncResult = {
72
+ preset: string
73
+ /** Discovered file -> the section name that actually governs it. */
74
+ names: Map<string, string>
75
+ }
76
+
77
+ /**
78
+ * Append sections for models that do not have one yet.
79
+ *
80
+ * The section name IS the model id, so a section the user renamed keeps
81
+ * winning: we match on the `model =` path first and only fall back to the
82
+ * proposed name for genuinely new files. Getting this backwards would append a
83
+ * duplicate default section beside a hand-tuned one and then read `ctx-size`
84
+ * from the wrong one — silently compacting far below the real window.
85
+ */
86
+ export async function sync(models: LocalModel[]): Promise<SyncResult> {
87
+ const existing = await fs.readFile(FILE, "utf8").catch(() => "")
88
+ const doc = Ini.parse(existing)
89
+ const names = new Set(doc.sections.map((item) => item.name))
90
+
91
+ const byFile = new Map<string, string>()
92
+ for (const item of doc.sections) {
93
+ const file = Ini.get(item, "model")?.trim()
94
+ if (file) byFile.set(file, item.name)
95
+ }
96
+
97
+ const added: string[] = []
98
+ const resolved = new Map<string, string>()
99
+ for (const model of models) {
100
+ const owner = byFile.get(model.file)
101
+ if (owner) {
102
+ resolved.set(model.file, owner)
103
+ continue
104
+ }
105
+ // a section named for this model but pointing elsewhere still owns the name
106
+ let id = model.id
107
+ for (let n = 2; names.has(id); n++) id = `${model.id}#${n}`
108
+ names.add(id)
109
+ resolved.set(model.file, id)
110
+ added.push(section({ ...model, id }))
111
+ }
112
+
113
+ const body = existing.trim().length > 0 ? existing.replace(/\s+$/, "") : HEADER
114
+ const next = added.length > 0 ? [body, "", added.join("\n\n"), ""].join("\n") : body + "\n"
115
+
116
+ await fs.mkdir(path.dirname(FILE), { recursive: true }).catch(() => {})
117
+ await fs.writeFile(FILE, next).catch(() => {})
118
+ return { preset: FILE, names: resolved }
119
+ }
120
+
121
+ /**
122
+ * Per-model context and sampling, read back out of the file.
123
+ *
124
+ * Context matters beyond display: opencode compacts a conversation against the
125
+ * model's advertised window, so if we reported a default while the model
126
+ * actually loaded with 245k, it would compact at a fraction of the real limit.
127
+ */
128
+ export async function settings(): Promise<Record<string, ModelSettings>> {
129
+ const text = await fs.readFile(FILE, "utf8").catch(() => "")
130
+ const out: Record<string, ModelSettings> = {}
131
+ for (const item of Ini.parse(text).sections) {
132
+ const raw = Ini.entries(item)
133
+ const context = Number.parseInt(raw["ctx-size"] ?? "", 10)
134
+ const sampling: Record<string, number> = {}
135
+ for (const key of SAMPLING_KEYS) {
136
+ const value = Number.parseFloat(raw[key] ?? "")
137
+ if (Number.isFinite(value)) sampling[SAMPLING_WIRE[key]] = value
138
+ }
139
+ out[item.name] = {
140
+ context: Number.isFinite(context) && context > 0 ? context : 0,
141
+ sampling,
142
+ }
143
+ }
144
+ return out
145
+ }
146
+
147
+ /** Reset one section to the shipped defaults, keeping model/mmproj paths. */
148
+ export async function restoreDefaults(id: string): Promise<boolean> {
149
+ const text = await fs.readFile(FILE, "utf8").catch(() => "")
150
+ const doc = Ini.parse(text)
151
+ const target = Ini.find(doc, id)
152
+ if (!target) return false
153
+ const raw = Ini.entries(target)
154
+ const lines = [`model = ${raw["model"] ?? ""}`]
155
+ if (raw["mmproj"]) lines.push(`mmproj = ${raw["mmproj"]}`)
156
+ for (const [key, value] of Object.entries(DEFAULTS)) lines.push(`${key} = ${value}`)
157
+ target.lines = lines
158
+ await fs.writeFile(FILE, Ini.serialize(doc)).catch(() => {})
159
+ return true
160
+ }
@@ -0,0 +1,110 @@
1
+ import fs from "fs/promises"
2
+ import path from "path"
3
+ import * as Ini from "../../shared/ini.ts"
4
+ import { configDir, expandHome, collapseHome } from "../../shared/paths.ts"
5
+
6
+ /**
7
+ * server.ini — ours, not llama.cpp's. We read it and build llama-server's
8
+ * command line from it. llama-server never sees this file.
9
+ *
10
+ * Two values have no safe default and are left empty until set:
11
+ * bin — filled from $PATH if llama-server is there, else empty
12
+ * models-dir — always empty on a fresh install; only the user knows this
13
+ *
14
+ * Empty is not a failure mode to hide. The panel reads it and asks.
15
+ */
16
+
17
+ export const BACKEND = "llamacpp"
18
+ export const FILE = path.join(configDir(BACKEND), "server.ini")
19
+
20
+ const TEMPLATE = `# llama-server settings for opencode-localhost.
21
+ # Changing anything here restarts the server.
22
+ #
23
+ # bin path to llama-server. empty = look on $PATH
24
+ # models-dir where your .gguf files live. REQUIRED, no default
25
+ # host 127.0.0.1 keeps it on this machine.
26
+ # 0.0.0.0 exposes it to your network — set api-key if you do
27
+ # port llama-server listen port
28
+ # models-max how many models may sit in VRAM at once. 1 swaps instead of stacking
29
+ # api-key required by the server when set. leave empty for localhost-only
30
+
31
+ [server]
32
+ bin =
33
+ models-dir =
34
+ host = 127.0.0.1
35
+ port = 9337
36
+ models-max = 1
37
+ api-key =
38
+ `
39
+
40
+ export type ServerSettings = {
41
+ bin: string
42
+ modelsDir: string
43
+ host: string
44
+ port: number
45
+ modelsMax: number
46
+ apiKey: string
47
+ }
48
+
49
+ const DEFAULTS: ServerSettings = {
50
+ bin: "",
51
+ modelsDir: "",
52
+ host: "127.0.0.1",
53
+ port: 9337,
54
+ modelsMax: 1,
55
+ apiKey: "",
56
+ }
57
+
58
+ function number(value: string | undefined, fallback: number) {
59
+ const parsed = Number.parseInt((value ?? "").trim(), 10)
60
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
61
+ }
62
+
63
+ /** Creates the file on first call so there is always something to point at. */
64
+ export async function load(): Promise<ServerSettings> {
65
+ let text = await fs.readFile(FILE, "utf8").catch(() => undefined)
66
+ if (text === undefined) {
67
+ await fs.mkdir(path.dirname(FILE), { recursive: true }).catch(() => {})
68
+ await fs.writeFile(FILE, TEMPLATE).catch(() => {})
69
+ text = TEMPLATE
70
+ }
71
+ const section = Ini.find(Ini.parse(text), "server")
72
+ if (!section) return { ...DEFAULTS }
73
+ const raw = Ini.entries(section)
74
+ return {
75
+ bin: expandHome((raw["bin"] ?? "").trim()),
76
+ modelsDir: expandHome((raw["models-dir"] ?? "").trim()),
77
+ host: (raw["host"] || DEFAULTS.host).trim(),
78
+ port: number(raw["port"], DEFAULTS.port),
79
+ modelsMax: number(raw["models-max"], DEFAULTS.modelsMax),
80
+ apiKey: (raw["api-key"] ?? "").trim(),
81
+ }
82
+ }
83
+
84
+ /** Writes one key back, preserving comments and everything else in the file. */
85
+ export async function update(key: "bin" | "models-dir", value: string): Promise<void> {
86
+ const text = await fs.readFile(FILE, "utf8").catch(() => TEMPLATE)
87
+ const doc = Ini.parse(text)
88
+ const section = Ini.find(doc, "server")
89
+ if (!section) return
90
+ Ini.set(section, key, collapseHome(value))
91
+ await fs.mkdir(path.dirname(FILE), { recursive: true }).catch(() => {})
92
+ await fs.writeFile(FILE, Ini.serialize(doc)).catch(() => {})
93
+ }
94
+
95
+ /** llama-server's command line, built from the settings plus the preset file. */
96
+ export function argv(settings: ServerSettings, preset: string): string[] {
97
+ return [
98
+ "--models-dir",
99
+ settings.modelsDir,
100
+ "--models-preset",
101
+ preset,
102
+ "--models-max",
103
+ String(settings.modelsMax),
104
+ "--host",
105
+ settings.host,
106
+ "--port",
107
+ String(settings.port),
108
+ ...(settings.apiKey ? ["--api-key", settings.apiKey] : []),
109
+ ]
110
+ }
@@ -0,0 +1,97 @@
1
+ import fs from "fs/promises"
2
+ import path from "path"
3
+ import os from "os"
4
+
5
+ /**
6
+ * The backends this plugin knows about.
7
+ *
8
+ * Only llama.cpp is implemented. The others are listed anyway so the setup
9
+ * screen can tell you what it found and what installing them would take —
10
+ * a greyed row with a real install command is more use than pretending the
11
+ * option does not exist.
12
+ *
13
+ * Note these are not the same kind of thing: llama.cpp ships a native binary
14
+ * with prebuilt releases, while vLLM, MLX and OpenVINO are Python packages.
15
+ * That is why this plugin never installs anything for you — "install" means
16
+ * something different for each, and picking the wrong llama.cpp build (CUDA
17
+ * version, GPU arch) silently costs a large multiple of performance.
18
+ */
19
+
20
+ export type BackendSpec = {
21
+ id: string
22
+ name: string
23
+ /** Executable to look for on $PATH. */
24
+ binary: string
25
+ /** What a user would actually run to get it. */
26
+ install: string
27
+ /** Restricts availability; MLX is Apple Silicon only. */
28
+ requires?: { platform: NodeJS.Platform; arch: string }
29
+ implemented: boolean
30
+ }
31
+
32
+ export const BACKENDS: BackendSpec[] = [
33
+ {
34
+ id: "llamacpp",
35
+ name: "llama.cpp",
36
+ binary: "llama-server",
37
+ install: "build from source, or: brew install llama.cpp",
38
+ implemented: true,
39
+ },
40
+ {
41
+ id: "vllm",
42
+ name: "vLLM",
43
+ binary: "vllm",
44
+ install: "pip install vllm",
45
+ implemented: false,
46
+ },
47
+ {
48
+ id: "mlx",
49
+ name: "MLX",
50
+ binary: "mlx_lm.server",
51
+ install: "pip install mlx-lm",
52
+ requires: { platform: "darwin", arch: "arm64" },
53
+ implemented: false,
54
+ },
55
+ {
56
+ id: "openvino",
57
+ name: "OpenVINO",
58
+ binary: "ovms",
59
+ install: "pip install openvino-genai",
60
+ implemented: false,
61
+ },
62
+ ]
63
+
64
+ export function supported(spec: BackendSpec): boolean {
65
+ if (!spec.requires) return true
66
+ return process.platform === spec.requires.platform && process.arch === spec.requires.arch
67
+ }
68
+
69
+ /** First match on $PATH, or undefined. We never guess at build directories. */
70
+ export async function onPath(binary: string): Promise<string | undefined> {
71
+ const dirs = (process.env["PATH"] || "").split(path.delimiter).filter(Boolean)
72
+ for (const dir of dirs) {
73
+ const candidate = path.join(dir, binary)
74
+ const stat = await fs.stat(candidate).catch(() => undefined)
75
+ if (stat?.isFile()) return candidate
76
+ }
77
+ return undefined
78
+ }
79
+
80
+ /** Every match on $PATH — several llama.cpp builds commonly coexist. */
81
+ export async function allOnPath(binary: string): Promise<string[]> {
82
+ const dirs = (process.env["PATH"] || "").split(path.delimiter).filter(Boolean)
83
+ const found: string[] = []
84
+ for (const dir of dirs) {
85
+ const candidate = path.join(dir, binary)
86
+ const stat = await fs.stat(candidate).catch(() => undefined)
87
+ if (stat?.isFile() && !found.includes(candidate)) found.push(candidate)
88
+ }
89
+ return found
90
+ }
91
+
92
+ export function expand(value: string): string {
93
+ const trimmed = value.trim()
94
+ if (trimmed === "~") return os.homedir()
95
+ if (trimmed.startsWith("~/")) return path.join(os.homedir(), trimmed.slice(2))
96
+ return trimmed
97
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * INI reader/writer that preserves everything it does not understand.
3
+ *
4
+ * These files are hand-edited — comments explaining why a model needs
5
+ * `ubatch-size = 2048` are the most valuable thing in them. A parse/serialize
6
+ * round trip that drops comments, ordering, or blank lines would quietly
7
+ * destroy that, so sections keep their raw lines and are only rewritten when a
8
+ * key in them actually changes.
9
+ */
10
+
11
+ export type Section = {
12
+ name: string
13
+ /** Raw lines between this header and the next, verbatim. */
14
+ lines: string[]
15
+ }
16
+
17
+ export type Document = {
18
+ /** Lines before the first section header. */
19
+ preamble: string[]
20
+ sections: Section[]
21
+ }
22
+
23
+ const HEADER = /^\s*\[([^\]]+)\]\s*$/
24
+ /** Keys may repeat (llama.cpp allows it); we address the first occurrence. */
25
+ const entry = (key: string) => new RegExp(`^(\\s*)${escape(key)}(\\s*=\\s*)(.*?)(\\s*)$`)
26
+
27
+ function escape(value: string) {
28
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
29
+ }
30
+
31
+ export function parse(text: string): Document {
32
+ const doc: Document = { preamble: [], sections: [] }
33
+ let current: Section | undefined
34
+ for (const line of text.split("\n")) {
35
+ const header = line.match(HEADER)
36
+ if (header) {
37
+ current = { name: header[1], lines: [] }
38
+ doc.sections.push(current)
39
+ continue
40
+ }
41
+ if (current) current.lines.push(line)
42
+ else doc.preamble.push(line)
43
+ }
44
+ return doc
45
+ }
46
+
47
+ export function serialize(doc: Document): string {
48
+ const out: string[] = [...doc.preamble]
49
+ for (const section of doc.sections) {
50
+ out.push(`[${section.name}]`)
51
+ out.push(...section.lines)
52
+ }
53
+ return out.join("\n").replace(/\n*$/, "\n")
54
+ }
55
+
56
+ export function get(section: Section, key: string): string | undefined {
57
+ for (const line of section.lines) {
58
+ const match = line.match(entry(key))
59
+ if (match) return match[3]
60
+ }
61
+ return undefined
62
+ }
63
+
64
+ /**
65
+ * Set a key in place, keeping its original spacing. Undefined removes it.
66
+ * Appends after the last existing entry rather than at the very end, so a
67
+ * trailing comment block stays at the bottom where the user put it.
68
+ */
69
+ export function set(section: Section, key: string, value: string | undefined): void {
70
+ const pattern = entry(key)
71
+ for (let i = 0; i < section.lines.length; i++) {
72
+ const match = section.lines[i].match(pattern)
73
+ if (!match) continue
74
+ if (value === undefined) section.lines.splice(i, 1)
75
+ else section.lines[i] = `${match[1]}${key}${match[2]}${value}`
76
+ return
77
+ }
78
+ if (value === undefined) return
79
+ let insert = section.lines.length
80
+ while (insert > 0 && !section.lines[insert - 1].includes("=")) insert--
81
+ section.lines.splice(insert, 0, `${key} = ${value}`)
82
+ }
83
+
84
+ export function find(doc: Document, name: string): Section | undefined {
85
+ return doc.sections.find((section) => section.name === name)
86
+ }
87
+
88
+ /** Every `key = value` in a section, first occurrence wins. Comments skipped. */
89
+ export function entries(section: Section): Record<string, string> {
90
+ const out: Record<string, string> = {}
91
+ for (const line of section.lines) {
92
+ if (/^\s*[#;]/.test(line)) continue
93
+ const match = line.match(/^\s*([^=\s]+)\s*=\s*(.*?)\s*$/)
94
+ if (match && !(match[1] in out)) out[match[1]] = match[2]
95
+ }
96
+ return out
97
+ }
@@ -0,0 +1,37 @@
1
+ import os from "os"
2
+ import path from "path"
3
+
4
+ /**
5
+ * Where a backend's files live. Config is what you edit; state is what we own.
6
+ *
7
+ * Config follows XDG so it sits next to opencode's own config, which is where
8
+ * people look for it. State holds pidfiles and logs — things you never edit and
9
+ * that should not survive in a dotfiles repo.
10
+ */
11
+
12
+ const xdg = (envVar: string, fallback: string) => process.env[envVar] || path.join(os.homedir(), fallback)
13
+
14
+ export const CONFIG_ROOT = path.join(xdg("XDG_CONFIG_HOME", ".config"), "opencode", "providers")
15
+ export const STATE_ROOT = path.join(xdg("XDG_STATE_HOME", ".local/state"), "opencode", "providers")
16
+
17
+ export function configDir(backend: string) {
18
+ return path.join(CONFIG_ROOT, backend)
19
+ }
20
+
21
+ export function stateDir(backend: string) {
22
+ return path.join(STATE_ROOT, backend)
23
+ }
24
+
25
+ /** `~/models` and `$HOME/models` both appear in hand-edited config files. */
26
+ export function expandHome(value: string): string {
27
+ if (value === "~") return os.homedir()
28
+ if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2))
29
+ if (value.startsWith("$HOME/")) return path.join(os.homedir(), value.slice(6))
30
+ return value
31
+ }
32
+
33
+ /** Written back to config so paths stay readable rather than machine-specific. */
34
+ export function collapseHome(value: string): string {
35
+ const home = os.homedir()
36
+ return value.startsWith(home + path.sep) ? "~" + value.slice(home.length) : value
37
+ }
@@ -0,0 +1,51 @@
1
+ /** Shapes the panel renders. Deliberately backend-agnostic. */
2
+
3
+ export type GpuStat = {
4
+ index: number
5
+ usedMiB: number
6
+ totalMiB: number
7
+ /** Percent 0-100. Undefined when the driver does not report it. */
8
+ utilization?: number
9
+ }
10
+
11
+ export type SystemStat = {
12
+ usedMiB: number
13
+ totalMiB: number
14
+ utilization?: number
15
+ }
16
+
17
+ export type LoadedModel = {
18
+ id: string
19
+ /** Launch flags the server reports, e.g. ctx-size, cache-type-k, gpu-layers. */
20
+ args: Record<string, string>
21
+ loading?: boolean
22
+ /** 0-1 while weights stream in. */
23
+ progress?: number
24
+ }
25
+
26
+ /**
27
+ * "unconfigured" is a first-class state, not an error: a fresh install has no
28
+ * models directory and the panel is where the user is told so.
29
+ */
30
+ export type ProviderStatus =
31
+ | { state: "unconfigured"; missing: "binary" | "models-dir"; message: string; hint?: string }
32
+ | { state: "stopped" }
33
+ | { state: "starting" }
34
+ | { state: "running"; endpoint: string; loaded?: LoadedModel }
35
+ | { state: "failed"; message: string; hint?: string }
36
+
37
+ export type PanelData = {
38
+ backend: { id: string; name: string }
39
+ status: ProviderStatus
40
+ /**
41
+ * Whether opencode has actually picked up the provider. False while the
42
+ * server is up but opencode has not re-read its config — the one case where
43
+ * a restart is genuinely required, so the panel has to say so.
44
+ */
45
+ registered?: boolean
46
+ gpus: GpuStat[]
47
+ memory?: SystemStat
48
+ cpu?: SystemStat
49
+ /** Tokens per second, while generating. */
50
+ throughput?: number
51
+ }
@@ -0,0 +1,52 @@
1
+ import { execFile } from "child_process"
2
+ import type { GpuStat } from "../../shared/types.ts"
3
+
4
+ /**
5
+ * GPU stats via nvidia-smi. There is no push interface, so this is polled —
6
+ * but only while the panel is visible.
7
+ *
8
+ * Absent nvidia-smi is a normal state, not an error: plenty of machines run
9
+ * llama.cpp on CPU, Metal, or ROCm. We return nothing and the panel omits the
10
+ * GPU rows rather than showing a failure.
11
+ */
12
+
13
+ const QUERY = "index,memory.used,memory.total,utilization.gpu"
14
+ const TIMEOUT = 2_000
15
+
16
+ let available = true
17
+
18
+ function run(): Promise<string | undefined> {
19
+ return new Promise((resolve) => {
20
+ execFile(
21
+ "nvidia-smi",
22
+ [`--query-gpu=${QUERY}`, "--format=csv,noheader,nounits"],
23
+ { timeout: TIMEOUT },
24
+ (error, stdout) => {
25
+ if (error) {
26
+ // ENOENT means no NVIDIA tooling at all; stop trying every tick
27
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") available = false
28
+ return resolve(undefined)
29
+ }
30
+ resolve(stdout)
31
+ },
32
+ )
33
+ })
34
+ }
35
+
36
+ export async function gpus(): Promise<GpuStat[]> {
37
+ if (!available) return []
38
+ const output = await run()
39
+ if (!output) return []
40
+ const stats: GpuStat[] = []
41
+ for (const line of output.trim().split("\n")) {
42
+ const parts = line.split(",").map((value) => Number.parseInt(value.trim(), 10))
43
+ if (parts.length < 3 || parts.some((value, index) => index < 3 && !Number.isFinite(value))) continue
44
+ stats.push({
45
+ index: parts[0],
46
+ usedMiB: parts[1],
47
+ totalMiB: parts[2],
48
+ utilization: Number.isFinite(parts[3]) ? parts[3] : undefined,
49
+ })
50
+ }
51
+ return stats
52
+ }
@@ -0,0 +1,68 @@
1
+ import fs from "fs/promises"
2
+ import os from "os"
3
+ import type { SystemStat } from "../../shared/types.ts"
4
+
5
+ /**
6
+ * System RAM and CPU.
7
+ *
8
+ * RAM uses MemAvailable rather than os.freemem(): free memory excludes page
9
+ * cache, so on a box that has just mmap'd a 20GB model it reads as almost
10
+ * nothing and looks alarming. MemAvailable is what "how much can I still use"
11
+ * actually means.
12
+ */
13
+
14
+ const MIB = 1024 * 1024
15
+
16
+ type CpuSample = { idle: number; total: number }
17
+
18
+ let previous: CpuSample | undefined
19
+
20
+ async function meminfo(): Promise<SystemStat | undefined> {
21
+ const text = await fs.readFile("/proc/meminfo", "utf8").catch(() => undefined)
22
+ if (!text) {
23
+ // non-Linux: free memory is the best available approximation
24
+ const total = os.totalmem() / MIB
25
+ const used = (os.totalmem() - os.freemem()) / MIB
26
+ return { usedMiB: Math.round(used), totalMiB: Math.round(total) }
27
+ }
28
+ const read = (key: string) => {
29
+ const match = text.match(new RegExp(`^${key}:\\s+(\\d+) kB`, "m"))
30
+ return match ? Number.parseInt(match[1], 10) / 1024 : undefined
31
+ }
32
+ const total = read("MemTotal")
33
+ const availableKiB = read("MemAvailable")
34
+ if (total === undefined || availableKiB === undefined) return undefined
35
+ return { usedMiB: Math.round(total - availableKiB), totalMiB: Math.round(total) }
36
+ }
37
+
38
+ /** Utilisation between the previous call and now; undefined on the first. */
39
+ async function cpuUtilisation(): Promise<number | undefined> {
40
+ const text = await fs.readFile("/proc/stat", "utf8").catch(() => undefined)
41
+ let sample: CpuSample | undefined
42
+ if (text) {
43
+ const line = text.split("\n")[0]
44
+ const values = line.split(/\s+/).slice(1).map(Number).filter(Number.isFinite)
45
+ if (values.length >= 5) {
46
+ const total = values.reduce((sum, value) => sum + value, 0)
47
+ sample = { idle: values[3] + (values[4] ?? 0), total }
48
+ }
49
+ }
50
+ if (!sample) {
51
+ const load = os.loadavg()[0]
52
+ const cores = os.cpus().length || 1
53
+ return Math.min(100, Math.round((load / cores) * 100))
54
+ }
55
+ const last = previous
56
+ previous = sample
57
+ if (!last) return undefined
58
+ const totalDelta = sample.total - last.total
59
+ const idleDelta = sample.idle - last.idle
60
+ if (totalDelta <= 0) return undefined
61
+ return Math.max(0, Math.min(100, Math.round(((totalDelta - idleDelta) / totalDelta) * 100)))
62
+ }
63
+
64
+ export async function system(): Promise<SystemStat | undefined> {
65
+ const memory = await meminfo()
66
+ if (!memory) return undefined
67
+ return { ...memory, utilization: await cpuUtilisation() }
68
+ }