@hmharness/kernel 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,111 @@
1
+ /**
2
+ * @hmharness/kernel - session
3
+ * Append-only JSONL session log under HMH_HOME/sessions/. Every loop event
4
+ * is durably recorded - the audit trail the 2026 consensus calls
5
+ * non-negotiable, and the raw material the evolution subsystem learns from.
6
+ */
7
+ import { appendFile, mkdir, readdir, readFile } from 'node:fs/promises';
8
+ import { join } from 'node:path';
9
+ export class Session {
10
+ id;
11
+ file;
12
+ /** Append chain: events serialize in call order, even fire-and-forget ones. */
13
+ tail = Promise.resolve();
14
+ constructor(home, cwd, model) {
15
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
16
+ this.id = `${stamp}-${Math.random().toString(36).slice(2, 8)}`;
17
+ this.file = join(home, 'sessions', `${this.id}.jsonl`);
18
+ this.append({ t: 'session/start', id: this.id, time: new Date().toISOString(), cwd, model }).catch(() => undefined);
19
+ }
20
+ async append(event) {
21
+ const write = this.tail.then(async () => {
22
+ const dir = join(this.file, '..');
23
+ await mkdir(dir, { recursive: true });
24
+ await appendFile(this.file, JSON.stringify(event) + '\n', 'utf8');
25
+ });
26
+ // keep the chain going even if this write fails
27
+ this.tail = write.catch(() => undefined);
28
+ await write;
29
+ }
30
+ user(text) {
31
+ return this.append({ t: 'user', time: new Date().toISOString(), text });
32
+ }
33
+ assistant(text, toolCalls) {
34
+ return this.append({ t: 'assistant', time: new Date().toISOString(), text, ...(toolCalls ? { tool_calls: toolCalls } : {}) });
35
+ }
36
+ tool(name, output, isError) {
37
+ return this.append({ t: 'tool', time: new Date().toISOString(), name, output, isError });
38
+ }
39
+ approval(tool, granted) {
40
+ return this.append({ t: 'approval', time: new Date().toISOString(), tool, granted });
41
+ }
42
+ final(text, turns, toolUses) {
43
+ return this.append({ t: 'final', time: new Date().toISOString(), text, turns, toolUses });
44
+ }
45
+ }
46
+ /** Find the newest session file under home/sessions matching an id prefix. */
47
+ export async function latestSession(home, prefix = '') {
48
+ let files;
49
+ try {
50
+ files = (await readdir(join(home, 'sessions'))).filter((f) => f.endsWith('.jsonl') && f.startsWith(prefix));
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ files.sort();
56
+ return files.length > 0 ? join(home, 'sessions', files[files.length - 1]) : null;
57
+ }
58
+ /**
59
+ * Rebuild a chat transcript from a session log. Tool events don't record
60
+ * tool_call_id, but the loop executes calls sequentially, so ids pair with
61
+ * the tool events that follow their assistant message in order.
62
+ */
63
+ export async function loadTranscript(file) {
64
+ let raw;
65
+ try {
66
+ raw = await readFile(file, 'utf8');
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ const out = { id: '', model: '', cwd: '', messages: [] };
72
+ let pendingCallIds = [];
73
+ for (const line of raw.split('\n')) {
74
+ if (!line.trim())
75
+ continue;
76
+ let ev;
77
+ try {
78
+ ev = JSON.parse(line);
79
+ }
80
+ catch {
81
+ continue;
82
+ }
83
+ if (ev.t === 'session/start') {
84
+ out.id = ev.id;
85
+ out.model = ev.model;
86
+ out.cwd = ev.cwd;
87
+ }
88
+ else if (ev.t === 'user') {
89
+ out.messages.push({ role: 'user', content: ev.text });
90
+ }
91
+ else if (ev.t === 'assistant') {
92
+ const calls = (ev.tool_calls ?? []);
93
+ out.messages.push({
94
+ role: 'assistant',
95
+ content: ev.text,
96
+ ...(calls.length > 0 ? { tool_calls: calls } : {}),
97
+ });
98
+ pendingCallIds = calls.map((c) => c.id);
99
+ }
100
+ else if (ev.t === 'tool') {
101
+ out.messages.push({
102
+ role: 'tool',
103
+ tool_call_id: pendingCallIds.shift() ?? '',
104
+ name: ev.name,
105
+ content: ev.output,
106
+ });
107
+ }
108
+ // approval / final events carry no chat turn
109
+ }
110
+ return out.id ? out : null;
111
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * @hmharness/kernel - types
3
+ * The kernel contract surface. Deliberately small: a Tool, a chat message,
4
+ * a provider config. Everything else in hmharness composes from these.
5
+ */
6
+ /** A JSON-Schema-shaped parameter description (OpenAI tool-call format). */
7
+ export interface JsonSchema {
8
+ type: string;
9
+ properties?: Record<string, unknown>;
10
+ required?: string[];
11
+ [key: string]: unknown;
12
+ }
13
+ /** Uniform tool execution result. */
14
+ export interface ToolResult {
15
+ output: string;
16
+ isError?: boolean;
17
+ }
18
+ /** Per-invocation context handed to every tool. */
19
+ export interface ToolContext {
20
+ /** Working directory for filesystem/shell tools. */
21
+ cwd: string;
22
+ /** hmharness home (isolated state root, e.g. ~/.hmharness). */
23
+ home: string;
24
+ }
25
+ /** A capability the agent may call. The entire extension surface. */
26
+ export interface Tool {
27
+ name: string;
28
+ description: string;
29
+ parameters: JsonSchema;
30
+ /**
31
+ * Declarative risk marker: when this returns true the loop must obtain
32
+ * user approval before executing (see LoopOptions.approval). Absent or
33
+ * false means read-only / safe. Remote (MCP) tools default to needing
34
+ * approval unless their server is marked trusted.
35
+ */
36
+ needsApproval?(args: Record<string, unknown>): boolean;
37
+ execute(args: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult>;
38
+ }
39
+ /** OpenAI-style chat message, reused across provider adapters. */
40
+ export interface ChatMessage {
41
+ role: 'system' | 'user' | 'assistant' | 'tool';
42
+ content: string | null;
43
+ tool_calls?: Array<{
44
+ id: string;
45
+ type: 'function';
46
+ function: {
47
+ name: string;
48
+ arguments: string;
49
+ };
50
+ }>;
51
+ tool_call_id?: string;
52
+ name?: string;
53
+ }
54
+ /** Connection settings for an OpenAI-compatible endpoint. */
55
+ export interface ProviderConfig {
56
+ baseUrl: string;
57
+ apiKey: string;
58
+ model: string;
59
+ /** Custom auth header name for gateways that reject Bearer (e.g. 'X-Api-Key'
60
+ * for freellmapi). Omit for standard Authorization: Bearer; on 401 the
61
+ * provider renegotiates with X-Api-Key automatically. */
62
+ authHeader?: string;
63
+ }
64
+ /** User-level configuration (HMH_HOME/config.json). */
65
+ export interface HmhConfig {
66
+ provider: ProviderConfig;
67
+ maxTurns: number;
68
+ /** 'ask' (default) prompts before risky tools; 'auto' approves everything. */
69
+ approval?: 'ask' | 'auto';
70
+ /** Rough context budget in chars before old tool outputs get pruned. */
71
+ maxContextChars?: number;
72
+ /** MCP servers whose tools are projected into the registry at startup. */
73
+ mcpServers?: Record<string, McpServerImport>;
74
+ /** SSH hosts the agent may operate (name -> {host,user,port,keyPath}).
75
+ * Secrets stay in HMH_HOME; the ssh_run tool reads them from here. */
76
+ sshHosts?: Record<string, {
77
+ host: string;
78
+ user: string;
79
+ port?: number;
80
+ keyPath?: string;
81
+ }>;
82
+ /** Vision-capable provider for see_image (any OpenAI-compatible endpoint). */
83
+ vision?: ProviderConfig;
84
+ /** Tried in order after `vision` fails (multi-provider resilience). */
85
+ visionFallbacks?: ProviderConfig[];
86
+ /** UI + system-prompt language. Default 'zh'. */
87
+ locale?: 'zh' | 'en';
88
+ /** Run a background evolution cycle after every N recorded insights
89
+ * (default 3; 0 disables). Tier 3 of the feedback ladder: Tier 1 = raw
90
+ * error self-notes (every task, zero cost), Tier 2 = one model-call
91
+ * lesson per erroring task (instant reflection), Tier 3 = this - full
92
+ * cycle with bench gate. Guards unchanged: double-gate, holdout, poison
93
+ * screen, writes only under skills/ and memory/. */
94
+ autoEvolveEvery?: number;
95
+ /** Named vendor endpoints for multi-provider routing. */
96
+ providers?: Record<string, ProviderConfig>;
97
+ /** Per-purpose provider names resolved against `providers`. */
98
+ routing?: {
99
+ /** main chat loop (default: `provider`) */
100
+ chat?: string;
101
+ /** see_image (default: `vision`) */
102
+ vision?: string;
103
+ /** evolution meta-calls (default: chat) */
104
+ evolve?: string;
105
+ /** bench runner (default: chat) */
106
+ bench?: string;
107
+ };
108
+ }
109
+ /** Resolve a purpose to a concrete provider config (routing > legacy fields). */
110
+ export declare function resolveProvider(cfg: HmhConfig, purpose: 'chat' | 'vision' | 'evolve' | 'bench'): ProviderConfig;
111
+ /** One row of `/model` listings: a named provider and what it currently serves. */
112
+ export interface ProviderView {
113
+ name: string;
114
+ model: string;
115
+ baseUrl: string;
116
+ /** purposes this provider resolves for right now (chat/vision/evolve/bench) */
117
+ purposes: string[];
118
+ }
119
+ export declare function listProviders(cfg: HmhConfig): ProviderView[];
120
+ /** Built-in OpenAI-compatible presets (source of truth for docs/PROVIDERS.md). */
121
+ export interface ProviderPreset {
122
+ name: string;
123
+ baseUrl: string;
124
+ envVar: string;
125
+ model: string;
126
+ /** auth header the gateway requires (freellmapi: X-Api-Key) */
127
+ authHeader?: string;
128
+ }
129
+ export declare const PROVIDER_PRESETS: ProviderPreset[];
130
+ /**
131
+ * Detect locally available providers, dsh-style: presets whose env var is
132
+ * set, plus anything configured in ~/.opencode (opencode.json providers).
133
+ * Already-configured names are excluded. Read-only - callers decide whether
134
+ * to merge into config via addProviders().
135
+ */
136
+ export declare function detectLocalProviders(cfg: HmhConfig, readFileFn: typeof import('node:fs/promises')['readFile']): Promise<ProviderPreset[]>;
137
+ /** Shape used in config.json (kernel/src/mcp.ts has the runtime client). */
138
+ export interface McpServerImport {
139
+ type: 'stdio' | 'http';
140
+ command?: string;
141
+ args?: string[];
142
+ env?: Record<string, string>;
143
+ url?: string;
144
+ headers?: Record<string, string>;
145
+ /** Skip the per-call approval prompt for this server's tools. */
146
+ trusted?: boolean;
147
+ }
package/dist/types.js ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * @hmharness/kernel - types
3
+ * The kernel contract surface. Deliberately small: a Tool, a chat message,
4
+ * a provider config. Everything else in hmharness composes from these.
5
+ */
6
+ /** Resolve a purpose to a concrete provider config (routing > legacy fields). */
7
+ export function resolveProvider(cfg, purpose) {
8
+ const named = cfg.routing?.[purpose] ?? (purpose === 'vision' ? undefined : cfg.routing?.chat);
9
+ if (named && cfg.providers?.[named])
10
+ return cfg.providers[named];
11
+ if (purpose === 'vision')
12
+ return cfg.vision ?? cfg.provider;
13
+ return cfg.provider;
14
+ }
15
+ export function listProviders(cfg) {
16
+ const purposesOf = (n) => {
17
+ const out = [];
18
+ for (const p of ['chat', 'vision', 'evolve', 'bench']) {
19
+ const named = cfg.routing?.[p] ?? (p !== 'vision' ? cfg.routing?.chat : undefined);
20
+ if (named === n)
21
+ out.push(p);
22
+ }
23
+ return out;
24
+ };
25
+ if (cfg.providers && Object.keys(cfg.providers).length) {
26
+ return Object.entries(cfg.providers).map(([name, p]) => ({ name, model: p.model, baseUrl: p.baseUrl, purposes: purposesOf(name) }));
27
+ }
28
+ return [{ name: 'default', model: cfg.provider.model, baseUrl: cfg.provider.baseUrl, purposes: ['chat', 'vision', 'evolve', 'bench'] }];
29
+ }
30
+ export const PROVIDER_PRESETS = [
31
+ { name: 'deepseek', baseUrl: 'https://api.deepseek.com/v1', envVar: 'DEEPSEEK_API_KEY', model: 'deepseek-chat' },
32
+ { name: 'kimi', baseUrl: 'https://api.moonshot.cn/v1', envVar: 'MOONSHOT_API_KEY', model: 'kimi-latest' },
33
+ { name: 'glm', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', envVar: 'ZHIPU_API_KEY', model: 'glm-4.7' },
34
+ { name: 'qwen', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', envVar: 'DASHSCOPE_API_KEY', model: 'qwen3-max' },
35
+ { name: 'openai', baseUrl: 'https://api.openai.com/v1', envVar: 'OPENAI_API_KEY', model: 'gpt-5' },
36
+ { name: 'siliconflow', baseUrl: 'https://api.siliconflow.cn/v1', envVar: 'SILICONFLOW_API_KEY', model: 'deepseek-ai/DeepSeek-V3.2-Exp' },
37
+ { name: 'openrouter', baseUrl: 'https://openrouter.ai/api/v1', envVar: 'OPENROUTER_API_KEY', model: 'openrouter/auto' },
38
+ { name: 'nvidia-nim', baseUrl: 'https://integrate.api.nvidia.com/v1', envVar: 'NVIDIA_API_KEY', model: 'meta/llama-3.2-90b-vision-instruct' },
39
+ { name: 'groq', baseUrl: 'https://api.groq.com/openai/v1', envVar: 'GROQ_API_KEY', model: 'llama-3.3-70b-versatile' },
40
+ { name: 'together', baseUrl: 'https://api.together.xyz/v1', envVar: 'TOGETHER_API_KEY', model: 'meta-llama/Llama-3.3-70B-Instruct-Turbo' },
41
+ { name: 'xai', baseUrl: 'https://api.x.ai/v1', envVar: 'XAI_API_KEY', model: 'grok-4' },
42
+ { name: 'minimax', baseUrl: 'https://api.minimaxi.com/v1', envVar: 'MINIMAX_API_KEY', model: 'MiniMax-M2' },
43
+ { name: 'volc-ark', baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', envVar: 'ARK_API_KEY', model: 'doubao-seed-1-6' },
44
+ { name: 'stepfun', baseUrl: 'https://api.stepfun.com/v1', envVar: 'STEPFUN_API_KEY', model: 'step-3' },
45
+ { name: 'hunyuan', baseUrl: 'https://api.hunyuan.cloud.tencent.com/v1', envVar: 'HUNYUAN_API_KEY', model: 'hunyuan-turbos-latest' },
46
+ { name: 'ollama', baseUrl: 'http://127.0.0.1:11434/v1', envVar: '', model: 'qwen3:8b' },
47
+ { name: 'lm-studio', baseUrl: 'http://127.0.0.1:1234/v1', envVar: '', model: 'local-model' },
48
+ ];
49
+ /**
50
+ * Detect locally available providers, dsh-style: presets whose env var is
51
+ * set, plus anything configured in ~/.opencode (opencode.json providers).
52
+ * Already-configured names are excluded. Read-only - callers decide whether
53
+ * to merge into config via addProviders().
54
+ */
55
+ export async function detectLocalProviders(cfg, readFileFn) {
56
+ const known = new Set(Object.keys(cfg.providers ?? {}));
57
+ const found = [];
58
+ for (const p of PROVIDER_PRESETS) {
59
+ if (known.has(p.name))
60
+ continue;
61
+ // cloud presets: available when their env var is set; local-inference
62
+ // presets are intentionally NOT auto-added (caller opts in by hand)
63
+ if (p.envVar && process.env[p.envVar])
64
+ found.push(p);
65
+ }
66
+ // local OpenAI-compatible gateways: an env key plus a live /v1/models on a
67
+ // common loopback port (e.g. freellmapi on 3002, ollama on 11434); the
68
+ // probe negotiates auth (Bearer, then X-Api-Key) and remembers the scheme
69
+ for (const [envVar, ports] of [
70
+ ['FREELLM_API_KEY', [3002, 8080]],
71
+ ['OPENAI_COMPAT_API_KEY', [8080, 3000]],
72
+ ]) {
73
+ if (!process.env[envVar] || found.some((x) => x.name === envVar.toLowerCase().replace('_api_key', '')))
74
+ continue;
75
+ for (const port of ports) {
76
+ try {
77
+ const key = process.env[envVar];
78
+ let r = await fetch(`http://127.0.0.1:${port}/v1/models`, {
79
+ headers: { Authorization: `Bearer ${key}` },
80
+ signal: AbortSignal.timeout(800),
81
+ });
82
+ let authHeader;
83
+ if (r.status === 401) {
84
+ r = await fetch(`http://127.0.0.1:${port}/v1/models`, {
85
+ headers: { 'X-Api-Key': key },
86
+ signal: AbortSignal.timeout(800),
87
+ });
88
+ if (r.ok)
89
+ authHeader = 'X-Api-Key';
90
+ }
91
+ if (!r.ok)
92
+ continue;
93
+ const d = (await r.json());
94
+ const model = d.data?.[0]?.id ?? 'auto';
95
+ found.push({ name: envVar.toLowerCase().replace('_api_key', ''), baseUrl: `http://127.0.0.1:${port}/v1`, envVar: `(local gateway, key from ${envVar})`, model, ...(authHeader ? { authHeader } : {}) });
96
+ break;
97
+ }
98
+ catch {
99
+ /* port closed - try next */
100
+ }
101
+ }
102
+ }
103
+ // opencode config carries provider ids + baseURLs + models
104
+ try {
105
+ const { homedir } = await import('node:os');
106
+ const { join } = await import('node:path');
107
+ for (const f of [join(homedir(), '.opencode', 'opencode.json'), join(process.cwd(), '.opencode.json')]) {
108
+ let raw;
109
+ try {
110
+ raw = await readFileFn(f, 'utf8');
111
+ }
112
+ catch {
113
+ continue;
114
+ }
115
+ const oc = JSON.parse(raw);
116
+ for (const [id, def] of Object.entries(oc.provider ?? {})) {
117
+ if (known.has(id) || found.some((x) => x.name === id))
118
+ continue;
119
+ const baseURL = def.options?.baseURL ?? '';
120
+ const firstModel = Object.keys(def.models ?? {})[0] ?? '';
121
+ if (baseURL && firstModel) {
122
+ found.push({ name: id, baseUrl: baseURL, envVar: `(opencode: ${f.includes('.opencode.json') && !f.includes(homedir()) ? 'project' : 'user'})`, model: firstModel });
123
+ }
124
+ }
125
+ }
126
+ }
127
+ catch {
128
+ /* unreadable opencode config is fine */
129
+ }
130
+ return found;
131
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@hmharness/kernel",
3
+ "version": "0.1.0",
4
+ "description": "hmharness kernel: tool registry, provider adapters, the agent loop, session log, config. Zero runtime dependencies (Node >=22 native fetch).",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "types": "dist/index.d.ts",
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "engines": {
18
+ "node": ">=22"
19
+ },
20
+ "license": "Apache-2.0",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/swsgbl/hmharness.git"
24
+ },
25
+ "scripts": {
26
+ "build": "tsc -p tsconfig.build.json"
27
+ }
28
+ }