feat(local-model): types and module skeleton for the hardware-aware recommendation

The contract three builders code against in parallel. Nothing is recommended
yet: the route answers 501 and the catalogue is empty.

- src/types/local-model.ts: HardwareFacts, the catalogue rows, HarnessFact,
  Candidate, Recommendation, LocalModelStatus, and LOCAL_MODEL_LABELS, the one
  list of sentences the server may put into the payload (the zh-CN dictionary
  is keyed by them).
- src/local-model/format.ts: formatGb, the one place a byte count becomes a
  size a person reads (decimal GB), and memClassGb.
- src/config/local-model-catalog.ts: the Zod schema (model ids are name:tag,
  lowercase, no slash) and an empty stock catalogue. No override file.
- Skeletons with their final signatures: hardware.ts, recommend.ts,
  harness-facts.ts, routes/local-model-routes.ts (injectable dependencies),
  public/local-model-ui.js with its script tag.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 14:56:13 +01:00
1 parent e9b2ecd552
commit 73ce6a1819
12 files changed
+824

No files matched your search

+122
View File
@@ -0,0 +1,122 @@
/**
* @fileoverview The local model catalogue: which models, harnesses and chips the recommendation
* knows, as a compiled constant validated by a Zod schema.
*
* SKELETON: the schema below is the shape of the contract (it mirrors `LocalModelCatalog` in
* src/types/local-model.ts) and the stock constant is EMPTY. Still to come here:
* - the model rows (ids and byte sizes from the registry manifests, `paramsB`, an empty
* `tested` array each; `flint:40b` stays out), the harness rows and the chips table;
* - the cross-row rules the schema cannot say field by field: unique model ids, unique harness
* `cli`s, `contexts` largest first, `chips` most specific first, `promptCacheTiers` ascending
* and starting at 0;
* - `getLocalModelCatalog()` parsing the constant once, on first use, never at import.
*
* Rules that hold from the start:
* - There is NO override file and no runtime download data in this stage: nothing here reads
* the data dir, and a data file can never decide what gets recommended.
* - A model id is `name:tag` in lowercase with no slash (`LOCAL_MODEL_ID_PATTERN`), so it can
* only ever name a model of the default registry and namespace.
* - A harness is a ROW (`{ cli, minContext, rank, gated?, slowFirstTurn? }`). The `cli` is a CLI
* registry id as data: no code may compare it with a literal. Whether that CLI exists and can
* take a custom model is checked against the registry at call time, in
* src/local-model/harness-facts.ts, never here.
*/
import { z } from 'zod';
import type { LocalModelCatalog } from '../types.js';
/** A model id: lowercase name, one colon, a tag. No slash, so no registry host or namespace can ride along. */
export const LOCAL_MODEL_ID_PATTERN = /^[a-z0-9][a-z0-9._-]*:[a-z0-9][a-z0-9._-]*$/;
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'must be an ISO date (YYYY-MM-DD)');
const positiveInt = z.number().int().positive();
const TestedOnSchema = z
.object({
chip: z.string().min(1).max(80),
memGb: positiveInt,
cli: z.string().min(1).max(24),
date: isoDate,
})
.strict();
const LocalModelEntrySchema = z
.object({
id: z.string().max(100).regex(LOCAL_MODEL_ID_PATTERN, 'must be name:tag in lowercase, without a slash'),
label: z.string().min(1).max(80),
license: z.string().min(1).max(40),
paramsB: z.number().positive(),
bytes: positiveInt,
kvBytesPerToken: positiveInt,
contexts: z.array(positiveInt).min(1),
rank: z.number().int(),
speedPer100Gbps: z.number().positive().optional(),
tested: z.array(TestedOnSchema),
})
.strict();
const LocalHarnessEntrySchema = z
.object({
cli: z.string().min(1).max(24),
minContext: positiveInt,
rank: z.number().int(),
gated: z.boolean().optional(),
slowFirstTurn: z.boolean().optional(),
})
.strict();
const ChipEntrySchema = z
.object({
match: z.string().min(1).max(40),
gbps: z.number().positive(),
})
.strict();
const PromptCacheTierSchema = z
.object({
minMemGb: z.number().int().nonnegative(),
bytes: z.number().int().nonnegative(),
})
.strict();
/** Validates a whole catalogue. Typed against `LocalModelCatalog`, so the two cannot drift apart. */
export const LocalModelCatalogSchema: z.ZodType<LocalModelCatalog> = z
.object({
schema: z.literal(1),
updated: isoDate,
minMacosMajor: positiveInt,
promptCacheTiers: z.array(PromptCacheTierSchema).min(1),
models: z.array(LocalModelEntrySchema),
harnesses: z.array(LocalHarnessEntrySchema),
chips: z.array(ChipEntrySchema),
})
.strict();
/**
* The catalogue that ships with Codeman. EMPTY for now (no model, harness or chip rows), but
* valid: with it `recommend()` can only answer verdict `none`.
*
* The two constants that are already decided are filled in: macOS 14 is the oldest version the
* runtime supports, and the prompt cache is 512 MiB on a Mac below 32 GB and 2 GiB from 32 GB.
*/
export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
schema: 1,
updated: '2026-11-06',
minMacosMajor: 14,
promptCacheTiers: [
{ minMemGb: 0, bytes: 512 * 1024 ** 2 },
{ minMemGb: 32, bytes: 2 * 1024 ** 3 },
],
models: [],
harnesses: [],
chips: [],
};
/**
* The catalogue in force. There is no override in this stage, so this is the stock constant;
* callers go through this function anyway, so a later stage can change where it comes from
* without touching them.
*/
export function getLocalModelCatalog(): LocalModelCatalog {
return STOCK_LOCAL_MODEL_CATALOG;
}
+42
View File
@@ -0,0 +1,42 @@
/**
* @fileoverview The formatters of the local model recommendation, and its byte units.
*
* `formatGb()` is the ONE place a byte count becomes a size a person reads: decimal GB (the
* unit the Finder and the model registry use). `hardware.ts` and `recommend.ts` call it to fill
* the `...Gb` text fields of the payload (`memGb`, `freeDiskGb`, `needGb`, `downloadGb`,
* `budgetGb`, and the size slots of a `LocalModelMessage`). Nothing else may turn bytes into a
* size for display, the browser included: it prints the text it was sent.
*
* GiB and MiB exist only for the budget math (reserves, the wired limit, the prompt cache) and
* never reach a person.
*
* Pure: no IO, no state.
*/
/** One decimal gigabyte, the unit a person reads. */
export const GB = 1_000_000_000;
/** One binary gigabyte. Budget math only. */
export const GIB = 1024 ** 3;
/** One binary megabyte. Budget math only. */
export const MIB = 1024 ** 2;
/**
* A byte count as decimal GB text, WITHOUT the unit: one decimal below 100 ('6.6', '17.7',
* '25.5'), a whole number from 100 ('212'). The caller's sentence supplies ' GB'.
* Anything that is not a positive finite number reads '0.0'.
*/
export function formatGb(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0.0';
const tenths = Math.round((bytes / GB) * 10) / 10;
return tenths >= 100 ? String(Math.round(bytes / GB)) : tenths.toFixed(1);
}
/**
* The memory size a Mac is sold as: whole binary gigabytes (17179869184 bytes is 16). This is
* `HardwareFacts.memClassGb`: the key of the tier rules ("below 36 GB") and of `TestedOn.memGb`.
* It names a class of Mac. A size for display is `formatGb()`.
*/
export function memClassGb(memBytes: number): number {
if (!Number.isFinite(memBytes) || memBytes <= 0) return 0;
return Math.round(memBytes / GIB);
}
+106
View File
@@ -0,0 +1,106 @@
/**
* @fileoverview The hardware probe of the local model recommendation: what this Mac is.
*
* SKELETON: the exported signatures are final, the bodies are not written yet. This module
* will hold:
* - `parseSysctl()`: a pure parser of `key: value` lines. The command runs WITHOUT `-n`, so a
* key that is missing on a machine (`sysctl.proc_translated` on Intel) cannot shift the
* others, and a line it does not know is ignored.
* - `readHardware()`: ONE bounded async spawn of `SYSCTL_PATH` with `SYSCTL_KEYS`, the free disk
* of the data dir through `fs.promises.statfs`, fallbacks from `node:os` when the spawn
* fails, and a 60 s cache.
*
* Rules:
* - Never throws and never rejects: a failed probe is an answer (`source: 'os-fallback'`), and
* off macOS nothing is spawned (`source: 'not-probed'`).
* - Never `system_profiler` (tens of milliseconds and a parse of free text), never a second
* spawn, never a synchronous exec on the server's event loop.
* - A non-zero exit is tolerated once `hw.memsize` parsed (a missing key makes sysctl exit 1).
* - `iogpu.wired_limit_mb` of 0 means "not set": it is reported as 0 and only a value above 0
* is ever used as a limit.
* - `memGb`, `memClassGb` and `freeDiskGb` are filled here through src/local-model/format.ts,
* the one place a size is formatted.
*/
import type { HardwareFacts } from '../types.js';
/** Absolute path, so the server's PATH (minimal under a service manager) cannot change what runs. */
export const SYSCTL_PATH = '/usr/sbin/sysctl';
/** The keys of the one spawn, in the order they are passed. */
export const SYSCTL_KEYS = [
'hw.memsize',
'machdep.cpu.brand_string',
'hw.optional.arm64',
'sysctl.proc_translated',
'iogpu.wired_limit_mb',
'kern.osproductversion',
'hw.model',
] as const;
/** Upper bound of the spawn (it answers in about 2 ms on a real Mac). */
export const SYSCTL_TIMEOUT_MS = 2000;
/** How long one probe result is reused. */
export const HARDWARE_CACHE_TTL_MS = 60_000;
/** What `parseSysctl()` read. Every field is absent when its key was missing or its value did not parse. */
export interface SysctlFacts {
/** `hw.memsize`. */
memBytes?: number;
/** `machdep.cpu.brand_string`. */
chip?: string;
/** `hw.optional.arm64` is 1. */
arm64?: boolean;
/** `sysctl.proc_translated` is 1. */
translated?: boolean;
/** `iogpu.wired_limit_mb`, as reported (0 stays 0). */
wiredLimitMb?: number;
/** `kern.osproductversion`. */
macosVersion?: string;
/** The leading number of `macosVersion`. */
macosMajor?: number;
/** `hw.model`. */
model?: string;
}
/** The IO of `readHardware()`, injectable so a test spawns nothing and reads no real disk. Each member defaults to the real thing. */
export interface HardwareProbeDeps {
/** Default `process.platform`. */
platform?: string;
/** Default `os.arch()`. */
arch?: string;
/** Runs the one spawn. Resolves its stdout, also after a non-zero exit, or `undefined` when it could not run or timed out. Never rejects. */
runSysctl?: () => Promise<string | undefined>;
/** Free bytes on the data dir's filesystem, or `undefined` when unknown. Never rejects. */
freeDiskBytes?: () => Promise<number | undefined>;
/** Default `os.totalmem()`. */
totalMemBytes?: () => number;
/** Default `os.cpus()[0]?.model`. */
cpuModel?: () => string | undefined;
/** Default `Date.now`. The clock of the cache. */
now?: () => number;
}
/**
* Parse the output of `sysctl <keys>` (no `-n`): one `key: value` line per key it knows.
* Pure. Unknown lines, blank lines and error lines are skipped.
*/
export function parseSysctl(text: string): SysctlFacts {
void text;
throw new Error('not implemented: parseSysctl');
}
/**
* What this machine is. Cached for `HARDWARE_CACHE_TTL_MS`; concurrent callers share one probe.
* Never rejects.
*/
export async function readHardware(deps?: HardwareProbeDeps): Promise<HardwareFacts> {
void deps;
throw new Error('not implemented: readHardware');
}
/** Drop the cached probe result (tests, and nothing else). */
export function resetHardwareCache(): void {
throw new Error('not implemented: resetHardwareCache');
}
+30
View File
@@ -0,0 +1,30 @@
/**
* @fileoverview What the server knows about each catalogue harness on this machine, for the
* local model recommendation. Server side: this is the one module of src/local-model/ that
* reads the CLI registry, so `recommend()` can stay pure and name no CLI.
*
* SKELETON: the exported signature is final, the body is not written yet. `buildHarnessFacts()`
* will, for every `LocalHarnessEntry` of the catalogue:
* - resolve its `cli` in the CLI registry AT CALL TIME (never in a module-level constant), and
* leave out a row whose CLI is unknown, disabled, or has no custom model mechanism
* (`capabilities.customModelInjection.kind` is `unsupported`);
* - report `label` from the registry entry and `installed` from the same probe the other
* routes use;
* - report `installable` only for a CLI that is not installed and whose install command for
* this platform needs no administrator rights (not one that starts with `brew` or `sudo`);
* - supply `acceptsModelId`, the same test a custom model launch applies to the model id
* before anything is written (the launch spec's model pattern).
*
* Rules:
* - No CLI id is compared with a literal here or anywhere: the id comes from the catalogue row
* and everything else from the registry entry it resolves to.
* - Read-only: nothing is installed, written or spawned beyond the existing availability probe.
*/
import type { HarnessFact, LocalModelCatalog } from '../types.js';
/** One fact per catalogue harness that resolves in the CLI registry, in catalogue order. */
export async function buildHarnessFacts(catalog: LocalModelCatalog): Promise<HarnessFact[]> {
void catalog;
throw new Error('not implemented: buildHarnessFacts');
}
+68
View File
@@ -0,0 +1,68 @@
/**
* @fileoverview The recommendation itself: which model, at which context, with which harness
* fits this Mac. Pure: no IO, no clock, no CLI registry, no data dir.
*
* SKELETON: the exported signatures are final, the bodies are not written yet. This module
* will hold:
* - `deviceBudget()`: L is the wired limit when it is above 0, else 2/3 of memory below 36 GB
* and 3/4 from 36 GB. B = min(L - 1 GiB, memory - reserve), with a reserve of 6 GiB up to
* 18 GB, 8 GiB up to 48 GB and 12 GiB from 64 GB.
* - `needBytes()`: F = weights + kvBytesPerToken * ctx + 1 GiB + the prompt cache tier of the
* catalogue for this Mac's memory.
* - `recommend()`: the candidates (model, context, harness) with `ctx >= harness.minContext`,
* the harness installed or installable, accepting the model id and not gated; the fit (F/B up
* to 0.85 good, up to 0.98 tight, above out); the speed (ok, slow or unknown: a chip that is
* in no chips row is unknown, a bandwidth is never guessed); the ranking (good before tight,
* then ok before unknown before slow, then model rank, harness rank, larger context); and the
* verdict.
*
* Rules:
* - Verdict `limited` when the best candidate's model has fewer than 12 billion parameters.
* - The word "Recommended" (`LOCAL_MODEL_LABELS.headlineTested`) only for a pick whose model
* has a `tested` entry matching this Mac. Otherwise `headlineOk` plus `noteUntested`.
* - Reasons for `none`: not-macos, intel, memory, disk (free disk minus the download is below
* 10 GiB for every fitting model), macos, no-harness.
* - Every label in the result comes from `LOCAL_MODEL_LABELS`, every size text from
* `formatGb()`. No number is spliced into a sentence here.
* - No CLI id is ever compared with a literal: a harness is a catalogue row plus a
* `HarnessFact`.
*/
import type {
DeviceBudget,
HardwareFacts,
HarnessFact,
LocalModelCatalog,
LocalModelEntry,
Recommendation,
} from '../types.js';
/** The memory a model may use on this Mac: the device limit L, the budget B and where L came from. */
export function deviceBudget(hw: HardwareFacts): DeviceBudget {
void hw;
throw new Error('not implemented: deviceBudget');
}
/** F: the bytes `model` needs in memory at a context of `ctx` tokens on this Mac. */
export function needBytes(model: LocalModelEntry, ctx: number, catalog: LocalModelCatalog, hw: HardwareFacts): number {
void model;
void ctx;
void catalog;
void hw;
throw new Error('not implemented: needBytes');
}
/**
* The recommendation for this Mac. `harnessFacts` holds one fact per catalogue harness the
* caller could resolve; a catalogue harness without a fact is not offered.
*/
export function recommend(
hw: HardwareFacts,
catalog: LocalModelCatalog,
harnessFacts: readonly HarnessFact[]
): Recommendation {
void hw;
void catalog;
void harnessFacts;
throw new Error('not implemented: recommend');
}
+2
View File
@@ -29,6 +29,7 @@
* | plan | PlanItem, PlanTaskStatus, TddPhase | In-memory → `GET /api/sessions/:id/plan/tasks` |
* | orchestrator | OrchestratorState, OrchestratorPlan, OrchestratorConfig, OrchestratorPersistState | `~/.codeman/state.json` → `GET /api/orchestrator/status` |
* | mcp-sync | McpSyncResult, McpSyncTargetResult | Other CLIs' own config files → `GET`/`POST /api/mcp-sync` |
* | local-model | HardwareFacts, LocalModelCatalog, Candidate, Recommendation, LocalModelStatus, LOCAL_MODEL_LABELS | In-memory only (nothing persisted) → `GET /api/local-model` |
*
* ## Cross-domain relationship map
*
@@ -77,3 +78,4 @@ export * from './webview.js';
export * from './intent.js';
export * from './foreign-tmux.js';
export * from './mcp-sync.js';
export * from './local-model.js';
+357
View File
@@ -0,0 +1,357 @@
/**
* @fileoverview Types of the hardware-aware local model recommendation: what the server sees of
* this Mac, the catalogue of models and harnesses, and the answer of `GET /api/local-model`.
*
* This stage is read-only: Codeman looks at the Mac and recommends. It downloads nothing,
* installs nothing and writes no file.
*
* Who produces what:
* - `HardwareFacts`: src/local-model/hardware.ts (one bounded `sysctl` spawn, never throws).
* - `LocalModelCatalog` and its rows: src/config/local-model-catalog.ts (a compiled constant).
* - `HarnessFact`: src/local-model/harness-facts.ts (server only, read from the CLI registry at
* call time; it holds a function, so it never goes over HTTP).
* - `Recommendation`, `Candidate`, `DeviceBudget`: src/local-model/recommend.ts (pure).
* - `LocalModelStatus`: src/web/routes/local-model-routes.ts (the `data` of the GET).
*
* Three rules every producer and consumer follows:
*
* 1. ONE unit for a size a person reads: decimal GB, computed from bytes by `formatGb()` in
* src/local-model/format.ts. Every such size travels twice, as bytes (`needBytes`) and as the
* text to show (`needGb`, a number without the unit, such as '25.5'). The browser prints the
* text and never divides bytes itself. GiB appears only inside the budget math.
* 2. Every sentence or label the SERVER chooses comes from `LOCAL_MODEL_LABELS` below, so the
* browser can translate it (`codemanT(text, vars)`) and its i18n test can list every string.
* Numbers are never spliced into a sentence here: they travel in `vars` and fill the
* `{placeholder}` slots.
* 3. A CLI id is DATA (`LocalHarnessEntry.cli`, `Candidate.cli`). No code compares one with a
* literal: what differs between harnesses is a catalogue field or a `HarnessFact`.
*/
// ---------- Hardware ----------
/**
* How `HardwareFacts` was obtained.
* - `sysctl`: the spawn answered and `hw.memsize` parsed.
* - `os-fallback`: on macOS, but the spawn failed or timed out; memory, architecture and the chip
* string come from `node:os`, the rest is absent.
* - `not-probed`: not macOS, so `sysctl` was never spawned.
*/
export type HardwareProbeSource = 'sysctl' | 'os-fallback' | 'not-probed';
/**
* What the server knows about the machine it runs on. Plain JSON (it is part of the GET payload),
* and always an answer: a failed probe yields the `os-fallback` or `not-probed` shape, never an
* exception.
*/
export interface HardwareFacts {
/** `process.platform` of the server. Anything but `darwin` ends in verdict `none`, reason `not-macos`. */
platform: string;
/** `os.arch()` of the server process. `x64` on Apple silicon means Node runs under Rosetta. */
arch: string;
/** `hw.optional.arm64` is 1: the MACHINE is Apple silicon whatever `arch` says. Absent when the key was not read. */
arm64?: boolean;
/** `sysctl.proc_translated` is 1: this process runs under Rosetta. Absent when the key was not read (Intel Macs lack it). */
translated?: boolean;
/** Installed memory in bytes (`hw.memsize`, else `os.totalmem()`). The ONLY memory input of the budget math. */
memBytes: number;
/** `memBytes` as a person reads it: decimal GB from `formatGb()`, without the unit ('17.2' for a Mac sold as 16 GB). */
memGb: string;
/** The memory size the Mac is sold as: whole binary gigabytes (16, 32, 48). Tier rules and `TestedOn.memGb` use it. */
memClassGb: number;
/** `machdep.cpu.brand_string` ('Apple M4 Pro'), else `os.cpus()[0].model`. */
chip?: string;
/** `hw.model` ('Mac16,10'). */
model?: string;
/** `kern.osproductversion` ('27.2'). */
macosVersion?: string;
/** The leading number of `macosVersion` (27). */
macosMajor?: number;
/** `iogpu.wired_limit_mb` exactly as reported. 0 means "not set" (both test Macs report 0); only a value above 0 is a limit. */
wiredLimitMb?: number;
/** Free bytes on the filesystem of Codeman's data dir (`statfs`: `bavail * bsize`). */
freeDiskBytes?: number;
/** `freeDiskBytes` as a person reads it (decimal GB, no unit). Present exactly when `freeDiskBytes` is. */
freeDiskGb?: string;
source: HardwareProbeSource;
}
// ---------- Catalogue ----------
/** One Mac a model and harness pair was really run on by us. A model row with a matching entry may be called "Recommended". */
export interface TestedOn {
/** The chip, as `machdep.cpu.brand_string` reports it ('Apple M4'). */
chip: string;
/** The Mac's memory as it is sold (`HardwareFacts.memClassGb`: 16, 32, 48). */
memGb: number;
/** The harness it was tested with: a CLI registry id, as data. */
cli: string;
/** ISO date of the test ('2026-11-06'). */
date: string;
}
/** One model of the catalogue. */
export interface LocalModelEntry {
/** `name:tag` exactly as the model registry lists it. Lowercase, one colon, no slash (`LOCAL_MODEL_ID_PATTERN`). */
id: string;
/** The name a person reads ('Qwen3.8 27B'). */
label: string;
/** SPDX id of the license ('Apache-2.0'). */
license: string;
/** Parameter count in billions. Under 12 the verdict is `limited`, never `ok`. */
paramsB: number;
/** Size of the weights in bytes: both the download and what they take in memory. */
bytes: number;
/** Bytes of KV cache one token of context costs. */
kvBytesPerToken: number;
/** Context lengths (tokens) the model may be run at, largest first. */
contexts: number[];
/** Preference among models that fit: the HIGHER rank wins. A judgement, not a measurement. */
rank: number;
/** Measured tokens per second per 100 GB/s of memory bandwidth. Absent: the speed of this model is unknown. */
speedPer100Gbps?: number;
/** Macs this model was really run on. Empty today for every row. */
tested: TestedOn[];
}
/** One harness (coding agent CLI) that can be connected to a local model. All of it is data. */
export interface LocalHarnessEntry {
/** A CLI registry id. Data only: never compared with a literal anywhere. */
cli: string;
/** The smallest context (tokens) at which this harness works. */
minContext: number;
/** Preference among harnesses: the HIGHER rank wins. */
rank: number;
/** True: listed in the catalogue but never offered yet (its use with a local model is unverified). */
gated?: boolean;
/** True: the first answer takes minutes, because the harness sends a very large fixed prompt. */
slowFirstTurn?: boolean;
}
/**
* Memory bandwidth of one chip family. `LocalModelCatalog.chips` is ordered most specific first
* ('M4 Max' before 'M4') and the first row whose `match` occurs in `HardwareFacts.chip` wins. A
* chip that matches no row has an UNKNOWN speed: a bandwidth is never guessed.
*/
export interface ChipEntry {
/** Text that must occur in the chip string ('M4 Max'). */
match: string;
/** Memory bandwidth in GB/s. */
gbps: number;
}
/**
* Host memory the runtime's prompt cache takes, by the size of the Mac. Tiers are ordered by
* `minMemGb` ascending and the last tier with `minMemGb <= HardwareFacts.memClassGb` applies.
*/
export interface PromptCacheTier {
/** Smallest memory size (as sold, binary gigabytes) this tier applies to. The first tier has 0. */
minMemGb: number;
bytes: number;
}
/** The catalogue: a compiled constant, validated by `LocalModelCatalogSchema`. No override file, no download data. */
export interface LocalModelCatalog {
schema: 1;
/** ISO date the rows were last checked ('2026-11-06'). Reported as `LocalModelStatus.catalogUpdated`. */
updated: string;
/** Oldest macOS major version a local model runs on. Below it: verdict `none`, reason `macos`. */
minMacosMajor: number;
promptCacheTiers: PromptCacheTier[];
models: LocalModelEntry[];
harnesses: LocalHarnessEntry[];
chips: ChipEntry[];
}
// ---------- Harness facts (server only) ----------
/**
* What the server knows about one catalogue harness on this machine. Built from the CLI registry
* at call time, so `recommend()` names no CLI. NOT serializable (it holds a function): it never
* leaves the server. `Candidate` carries the parts a browser needs.
*/
export interface HarnessFact {
/** The CLI registry id. */
cli: string;
/** The registry's display label ('OpenCode'). */
label: string;
/** The CLI's binary was found on this machine. */
installed: boolean;
/** Not installed, but the registry has an install command for this platform that needs no administrator rights. */
installable: boolean;
/** Whether this CLI can be launched with that model id (its command-line pattern accepts it). */
acceptsModelId: (modelId: string) => boolean;
}
// ---------- Recommendation ----------
/** Need over budget: up to 0.85 is `good`, up to 0.98 is `tight`, above is `out`. */
export type FitGrade = 'good' | 'tight' | 'out';
/**
* Expected generation speed. `unknown` when the chip is in no `ChipEntry` or the model has no
* `speedPer100Gbps`. In the ranking `unknown` sits between `ok` and `slow`.
*/
export type SpeedGrade = 'ok' | 'slow' | 'unknown';
/**
* Why the verdict is `none`.
* - `not-macos`: the server does not run on macOS (the card is hidden).
* - `intel`: a Mac without Apple silicon.
* - `memory`: no model fits the memory budget with any harness.
* - `disk`: models fit the memory, but for every one of them free disk minus the download is below 10 GiB.
* - `macos`: macOS is older than `LocalModelCatalog.minMacosMajor`.
* - `no-harness`: no catalogue harness is installed or installable and accepts a model.
*/
export type NoneReason = 'not-macos' | 'intel' | 'memory' | 'disk' | 'macos' | 'no-harness';
/** Where the device budget came from: a formula (`estimated`) or `iogpu.wired_limit_mb` above 0 (`wired-limit`). */
export type BudgetSource = 'estimated' | 'wired-limit';
/** The memory a model may use on this Mac. */
export interface DeviceBudget {
/** L: the wired limit when above 0, else 2/3 of memory below 36 GB and 3/4 from 36 GB. */
limitBytes: number;
/** B = min(L - 1 GiB, memory - reserve). What `Candidate.budgetShare` is measured against. */
budgetBytes: number;
source: BudgetSource;
}
/**
* Every sentence and label the server may put into the payload, in English (the i18n source
* language). Frozen for the parallel build: the zh-CN dictionary in src/web/public/i18n.js has
* one entry per value, keyed by this exact text, and the browser's i18n test imports this
* constant to list them. Adding a key is fine; changing a text means changing its zh-CN key too.
*
* `{placeholder}` slots are filled by the browser from `LocalModelMessage.vars`. `{name}` is
* taken (the translator fills it with the display name), so no slot here is called `name`.
*
* | Key | Slots (all text; sizes are decimal GB without the unit) |
* |---------------|---------------------------------------------------------------------------------|
* | `noneMemory` | `memory`: `HardwareFacts.memGb` |
* | `noneDisk` | `download`: smallest download among the models that fit the memory; `free`: |
* | | `HardwareFacts.freeDiskGb`; `missing`: download + 10 GiB - free |
* | `noneMacos` | `version`: `HardwareFacts.macosVersion`; `minimum`: the catalogue's minimum |
*
* The word "Recommended" exists only in `headlineTested`, which may be used only when the
* pick's `tested` is true. No catalogue row has a tested entry today, so it is never sent.
*/
export const LOCAL_MODEL_LABELS = {
/** Verdict `ok`, pick not tested by us on a Mac like this one. */
headlineOk: 'Best fit for this Mac',
/** Verdict `ok`, pick tested by us on a Mac like this one. */
headlineTested: 'Recommended for this Mac',
/** Verdict `limited`. */
headlineLimited:
'This Mac can run a small model. It handles small edits and questions. It does not replace a cloud model.',
/** A note under an `ok` or `limited` pick whose `tested` is false. */
noteUntested: 'Not yet tested by us on a Mac like this.',
fitGood: 'Good',
fitTight: 'Tight',
speedSlow: 'Slow',
speedUnknown: 'Speed unknown',
noneNotMacos: 'Codeman recommends a local model only on a Mac with Apple silicon.',
noneIntel: 'This Mac has an Intel processor. Local coding models need Apple silicon.',
noneMemory:
'This Mac has {memory} GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.',
noneDisk:
'Not enough free disk space. The smallest model that fits this Mac is a {download} GB download and {free} GB is free. Free up about {missing} GB.',
noneMacos: 'This Mac runs macOS {version}. Local models need macOS {minimum} or newer.',
noneNoHarness: 'No coding agent that Codeman can connect to a local model is available on this Mac.',
} as const;
/** One of the texts in `LOCAL_MODEL_LABELS`. The payload's label fields are typed with it, so an off-list string does not compile. */
export type LocalModelLabel = (typeof LOCAL_MODEL_LABELS)[keyof typeof LOCAL_MODEL_LABELS];
/** The headline of each `none` reason. */
export const LOCAL_MODEL_NONE_LABELS: Record<NoneReason, LocalModelLabel> = {
'not-macos': LOCAL_MODEL_LABELS.noneNotMacos,
intel: LOCAL_MODEL_LABELS.noneIntel,
memory: LOCAL_MODEL_LABELS.noneMemory,
disk: LOCAL_MODEL_LABELS.noneDisk,
macos: LOCAL_MODEL_LABELS.noneMacos,
'no-harness': LOCAL_MODEL_LABELS.noneNoHarness,
};
/** A sentence for the card: a text from `LOCAL_MODEL_LABELS` plus the values of its `{placeholder}` slots. */
export interface LocalModelMessage {
text: LocalModelLabel;
/** Slot values, already formatted as text. Absent when the text has no slot. */
vars?: Record<string, string>;
}
/** One model at one context with one harness, measured against this Mac. Plain JSON. */
export interface Candidate {
/** `LocalModelEntry.id`. */
modelId: string;
/** `LocalModelEntry.label`. */
modelLabel: string;
license: string;
paramsB: number;
/** Context length in tokens. */
context: number;
/** The harness: a CLI registry id, as data. */
cli: string;
/** The harness's display label, from the CLI registry. */
cliLabel: string;
/** False: the harness is not on this machine yet (it is installable, or it would not be a candidate). */
cliInstalled: boolean;
/** The harness row's `slowFirstTurn`. */
slowFirstTurn?: boolean;
/** F = weights + kvBytesPerToken * context + 1 GiB + prompt cache. */
needBytes: number;
/** `needBytes` as decimal GB text, no unit. */
needGb: string;
/** The model download in bytes (`LocalModelEntry.bytes`). */
downloadBytes: number;
/** `downloadBytes` as decimal GB text, no unit. */
downloadGb: string;
/** `needBytes / Recommendation.budgetBytes` (0.83). */
budgetShare: number;
/** A candidate in a `Recommendation` is never `out`. */
fit: Exclude<FitGrade, 'out'>;
/** `fitGood` or `fitTight`. */
fitLabel: LocalModelLabel;
speed: SpeedGrade;
/** `speedSlow` or `speedUnknown`. Absent when `speed` is `ok`. */
speedLabel?: LocalModelLabel;
/** Estimated generation speed. Present exactly when `speed` is not `unknown`. */
tokensPerSecond?: number;
/** A `TestedOn` entry of the model matches this Mac (same chip, same `memClassGb`) and this harness. */
tested: boolean;
}
/**
* The answer of `recommend()`.
* - `ok`: `pick` is the best candidate and its model has at least 12 billion parameters.
* - `limited`: `pick` is the best candidate, but its model has fewer than 12 billion parameters.
* The card says so and never says "Recommended".
* - `none`: no pick; `reason` says why.
*/
export interface Recommendation {
verdict: 'ok' | 'limited' | 'none';
/** Present exactly when `verdict` is `none`. */
reason?: NoneReason;
/** What the card says first: `headlineOk`, `headlineTested`, `headlineLimited` or the `none` reason's sentence. */
headline: LocalModelMessage;
/** Further sentences under the headline (`noteUntested` today). May be empty. */
notes: LocalModelMessage[];
/** Present exactly when `verdict` is `ok` or `limited`. */
pick?: Candidate;
/** The next models that fit (at most four, each at its best context and harness), best first. Empty when `verdict` is `none`. */
alternatives: Candidate[];
/** `DeviceBudget.budgetBytes`. 0 when the budget could not be computed (not macOS). */
budgetBytes: number;
/** `budgetBytes` as decimal GB text, no unit. */
budgetGb: string;
budgetSource: BudgetSource;
}
// ---------- API ----------
/** The `data` of `GET /api/local-model`. Read-only; admin only in multi-user mode. */
export interface LocalModelStatus {
hardware: HardwareFacts;
recommendation: Recommendation;
/** `LocalModelCatalog.updated`. */
catalogUpdated: string;
}
+1
View File
@@ -4685,6 +4685,7 @@
<script defer src="foreign-sessions.js"></script>
<script defer src="git-status-ui.js"></script>
<script defer src="edit-history-ui.js"></script>
<script defer src="local-model-ui.js"></script>
<script defer src="entrance-animations.js"></script>
<script defer src="ralph-wizard.js"></script>
<script defer src="api-client.js"></script>
+33
View File
@@ -0,0 +1,33 @@
/**
* @fileoverview The "model on this Mac" card of the welcome screen and its row in App Settings.
*
* SKELETON: this file holds no code yet. It will read `GET /api/local-model` (hardware of the
* machine the server runs on, the recommendation, the catalogue date) and show, read-only:
* - the hardware line (chip, memory, free disk);
* - the verdict: the headline and notes the server sent, then the pick with its numbers
* (model, harness, context, memory need against the budget, download size, speed);
* - the other models that fit, each with its fit and speed label;
* - one line saying that setting the model up from Codeman is the next step and is not built
* yet.
*
* Rules:
* - Read-only: no button here downloads, installs or writes anything.
* - Hidden when the server is not on macOS (`recommendation.reason` is `not-macos`), when the
* request is refused (a non-admin in multi-user mode gets a 403) and after the user dismissed
* the card on this device (a localStorage key, never a server setting).
* - Every dynamic string goes in through `textContent`. Model, harness and chip names are
* someone else's text and carry `data-i18n-skip`.
* - Names and labels come from the payload: this file holds no list of models or harnesses and
* never compares a CLI id with a literal.
* - Sizes are printed exactly as sent (`needGb`, `downloadGb`, `budgetGb`, `memGb`,
* `freeDiskGb`: decimal GB as text). Bytes are never divided here.
* - A sentence the server chose arrives as `{ text, vars }` and goes through
* `codemanT(text, vars)`; this file's own strings are literals in its translator call, so the
* i18n test can harvest them. Both kinds need a zh-CN entry in i18n.js.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (CodemanApp)
* @dependency api-client.js (_api)
* @dependency i18n.js (codemanT, optional)
* @loadorder 12.59 of 16, after edit-history-ui.js, before entrance-animations.js
*/
+1
View File
@@ -49,5 +49,6 @@ export {
type CustomModelSwapDisplacement,
} from './custom-model-routes.js';
export { registerCliRegistryRoutes, readCliManagementEnabled, type CliListItem } from './cli-registry-routes.js';
export { registerLocalModelRoutes } from './local-model-routes.js';
export { registerAgentSessionRoutes } from './agent-session-routes.js';
+60
View File
@@ -0,0 +1,60 @@
/**
* @fileoverview `GET /api/local-model`: what this Mac is and which local model fits it.
*
* SKELETON: the route is registered and answers 501 until its handler is written. It will
* answer `{ success: true, data: LocalModelStatus }`, built from the hardware probe, the
* catalogue, the harness facts and the pure `recommend()`:
* `{ hardware, recommendation, catalogUpdated }`.
*
* Rules:
* - Read-only. It downloads nothing, installs nothing, writes no file and reads no setting.
* - Never a 500 for a probe that failed: a failed probe is an answer (the hardware's `source`
* says so and the recommendation is verdict `none`).
* - The payload names hardware of the host, so in multi-user mode it is admin only (403), the
* same bar as `GET /api/doctor`.
* - Every dependency is injected (`LocalModelRouteDeps`), so a route test spawns nothing and
* does not need a Mac: it passes its own hardware and harness facts.
*/
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import {
ApiErrorCode,
createErrorResponse,
type ApiResponse,
type HardwareFacts,
type HarnessFact,
type LocalModelCatalog,
type LocalModelStatus,
type Recommendation,
} from '../../types.js';
import { getLocalModelCatalog } from '../../config/local-model-catalog.js';
import { readHardware } from '../../local-model/hardware.js';
import { buildHarnessFacts } from '../../local-model/harness-facts.js';
import { recommend } from '../../local-model/recommend.js';
/** What the route calls. Each member defaults to the real module; a test overrides what it needs. */
export interface LocalModelRouteDeps {
readHardware: () => Promise<HardwareFacts>;
getCatalog: () => LocalModelCatalog;
buildHarnessFacts: (catalog: LocalModelCatalog) => Promise<HarnessFact[]>;
recommend: (hw: HardwareFacts, catalog: LocalModelCatalog, harnessFacts: readonly HarnessFact[]) => Recommendation;
}
export const defaultLocalModelRouteDeps: LocalModelRouteDeps = {
readHardware: () => readHardware(),
getCatalog: getLocalModelCatalog,
buildHarnessFacts,
recommend,
};
export function registerLocalModelRoutes(app: FastifyInstance, deps: Partial<LocalModelRouteDeps> = {}): void {
const resolved: LocalModelRouteDeps = { ...defaultLocalModelRouteDeps, ...deps };
void resolved;
app.get(
'/api/local-model',
async (_req: FastifyRequest, reply: FastifyReply): Promise<ApiResponse<LocalModelStatus>> => {
reply.code(501);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'GET /api/local-model is not implemented yet');
}
);
}
+2
View File
@@ -264,6 +264,7 @@ import {
pruneIdleLlamaSwapLogTails,
tryWebviewRefererFallback,
registerCliRegistryRoutes,
registerLocalModelRoutes,
} from './routes/index.js';
import { isLostWebviewFrameNavigation } from './webview-proxy.js';
import { CronService } from '../cron/cron-service.js';
@@ -1417,6 +1418,7 @@ export class WebServer extends EventEmitter {
registerDoctorRoutes(this.app);
registerCustomModelRoutes(this.app);
registerCliRegistryRoutes(this.app);
registerLocalModelRoutes(this.app);
// Cron: build the service from the same context, recompute
// due times for any persisted jobs, then expose it to its routes.