@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,34 @@
1
+ import type { HmhConfig } from './types.ts';
2
+ export declare const STATE_DIRS: readonly ["sessions", "memory", "skills", "insights", "bench"];
3
+ export declare function homeDir(): string;
4
+ export declare function defaultConfig(): HmhConfig;
5
+ export declare function loadConfig(): Promise<HmhConfig>;
6
+ /**
7
+ * Point routing.chat at a named provider (`/model <name>` in the TUI/REPL,
8
+ * the model picker in the web UI). Preserves every other config field; the
9
+ * returned config reflects the new route (HMH_LOCALE override reapplied).
10
+ */
11
+ export declare function setChatRoute(name: string): Promise<HmhConfig>;
12
+ /**
13
+ * Persist the UI locale (TUI/REPL `/lang`) to config.json. Same
14
+ * read-mutate-write shape as setChatRoute; returns the refreshed config.
15
+ */
16
+ export declare function setLocale(locale: 'zh' | 'en'): Promise<HmhConfig>;
17
+ /**
18
+ * Merge detected providers into config.json (`hmh providers --scan`).
19
+ * Same-name entries never overwrite what is already configured; returns the
20
+ * refreshed config and the names actually added.
21
+ */
22
+ export declare function addProviders(items: Array<{
23
+ name: string;
24
+ baseUrl: string;
25
+ model: string;
26
+ apiKey?: string;
27
+ }>): Promise<{
28
+ cfg: HmhConfig;
29
+ added: string[];
30
+ }>;
31
+ export declare function initHome(): Promise<{
32
+ home: string;
33
+ created: string[];
34
+ }>;
package/dist/config.js ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * @hmharness/kernel - config
3
+ * HMH_HOME isolation: all hmharness state lives under one root
4
+ * (env HMH_HOME wins, default ~/.hmharness). Nothing is ever shared with
5
+ * any other harness on the machine - the lesson that motivated this
6
+ * clean-room project.
7
+ */
8
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
9
+ import { homedir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ export const STATE_DIRS = ['sessions', 'memory', 'skills', 'insights', 'bench'];
12
+ export function homeDir() {
13
+ return process.env.HMH_HOME ?? join(homedir(), '.hmharness');
14
+ }
15
+ export function defaultConfig() {
16
+ return {
17
+ // Point at any OpenAI-compatible endpoint via config.json or env vars.
18
+ provider: {
19
+ baseUrl: process.env.HMH_BASE_URL ?? '',
20
+ apiKey: process.env.HMH_API_KEY ?? '',
21
+ model: process.env.HMH_MODEL ?? '',
22
+ },
23
+ maxTurns: 25,
24
+ };
25
+ }
26
+ /** HMH_LOCALE env (zh|en) overrides the configured locale - used by --locale. */
27
+ function applyLocaleOverride(cfg) {
28
+ const env = process.env.HMH_LOCALE;
29
+ return env === 'zh' || env === 'en' ? { ...cfg, locale: env } : cfg;
30
+ }
31
+ export async function loadConfig() {
32
+ const home = homeDir();
33
+ const file = join(home, 'config.json');
34
+ try {
35
+ const raw = JSON.parse(await readFile(file, 'utf8'));
36
+ return applyLocaleOverride({ ...defaultConfig(), ...raw, provider: { ...defaultConfig().provider, ...raw.provider } });
37
+ }
38
+ catch {
39
+ return applyLocaleOverride(defaultConfig());
40
+ }
41
+ }
42
+ /**
43
+ * Point routing.chat at a named provider (`/model <name>` in the TUI/REPL,
44
+ * the model picker in the web UI). Preserves every other config field; the
45
+ * returned config reflects the new route (HMH_LOCALE override reapplied).
46
+ */
47
+ export async function setChatRoute(name) {
48
+ const home = homeDir();
49
+ const file = join(home, 'config.json');
50
+ let raw = {};
51
+ try {
52
+ raw = JSON.parse(await readFile(file, 'utf8'));
53
+ }
54
+ catch {
55
+ /* fresh config */
56
+ }
57
+ if (!raw.providers || !(name in raw.providers)) {
58
+ throw new Error(`unknown provider "${name}" - configure it under providers in config.json first`);
59
+ }
60
+ raw.routing = { ...(raw.routing ?? {}), chat: name };
61
+ await writeFile(file, JSON.stringify(raw, null, 2) + '\n', 'utf8');
62
+ return loadConfig();
63
+ }
64
+ /**
65
+ * Persist the UI locale (TUI/REPL `/lang`) to config.json. Same
66
+ * read-mutate-write shape as setChatRoute; returns the refreshed config.
67
+ */
68
+ export async function setLocale(locale) {
69
+ const file = join(homeDir(), 'config.json');
70
+ let raw = {};
71
+ try {
72
+ raw = JSON.parse(await readFile(file, 'utf8'));
73
+ }
74
+ catch {
75
+ /* fresh config */
76
+ }
77
+ raw.locale = locale;
78
+ await writeFile(file, JSON.stringify(raw, null, 2) + '\n', 'utf8');
79
+ return loadConfig();
80
+ }
81
+ /**
82
+ * Merge detected providers into config.json (`hmh providers --scan`).
83
+ * Same-name entries never overwrite what is already configured; returns the
84
+ * refreshed config and the names actually added.
85
+ */
86
+ export async function addProviders(items) {
87
+ const home = homeDir();
88
+ const file = join(home, 'config.json');
89
+ let raw = {};
90
+ try {
91
+ raw = JSON.parse(await readFile(file, 'utf8'));
92
+ }
93
+ catch {
94
+ /* fresh config */
95
+ }
96
+ const providers = (raw.providers ?? {});
97
+ const added = [];
98
+ for (const it of items) {
99
+ if (providers[it.name])
100
+ continue;
101
+ providers[it.name] = { baseUrl: it.baseUrl, model: it.model, ...(it.apiKey ? { apiKey: it.apiKey } : {}) };
102
+ added.push(it.name);
103
+ }
104
+ raw.providers = providers;
105
+ await writeFile(file, JSON.stringify(raw, null, 2) + '\n', 'utf8');
106
+ return { cfg: await loadConfig(), added };
107
+ }
108
+ export async function initHome() {
109
+ const home = homeDir();
110
+ const created = [];
111
+ await mkdir(home, { recursive: true });
112
+ for (const dir of STATE_DIRS) {
113
+ const p = join(home, dir);
114
+ try {
115
+ await mkdir(p, { recursive: true });
116
+ }
117
+ catch {
118
+ /* exists */
119
+ }
120
+ }
121
+ const configFile = join(home, 'config.json');
122
+ try {
123
+ await readFile(configFile, 'utf8');
124
+ }
125
+ catch {
126
+ await writeFile(configFile, JSON.stringify(defaultConfig(), null, 2) + '\n', 'utf8');
127
+ created.push('config.json');
128
+ }
129
+ return { home, created };
130
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @hmharness/kernel - context
3
+ * Char-budget context compaction. Long agent runs are dominated by stale
4
+ * tool output; when the transcript exceeds its budget we replace the oldest
5
+ * tool results (never the system prompt, never the task itself, never the
6
+ * recent tail) with a tombstone. Deterministic, no model call, no surprise.
7
+ */
8
+ import type { ChatMessage } from './types.ts';
9
+ export declare const DEFAULT_CONTEXT_CHARS = 160000;
10
+ export declare function transcriptChars(messages: ChatMessage[]): number;
11
+ export declare function compactMessages(messages: ChatMessage[], budget?: number): ChatMessage[];
@@ -0,0 +1,32 @@
1
+ export const DEFAULT_CONTEXT_CHARS = 160_000;
2
+ export function transcriptChars(messages) {
3
+ return messages.reduce((n, m) => n + (m.content?.length ?? 0) + (m.tool_calls?.length ?? 0) * 80, 0);
4
+ }
5
+ /** Messages too old to prune - keep the opening (system+task) and the tail. */
6
+ function protectedRange(messages) {
7
+ const keep = new Set();
8
+ // system + first user message always survive
9
+ for (let i = 0; i < messages.length; i++) {
10
+ if (messages[i].role === 'system')
11
+ keep.add(i);
12
+ if (messages[i].role === 'user') {
13
+ keep.add(i);
14
+ break;
15
+ }
16
+ }
17
+ for (let i = Math.max(0, messages.length - 8); i < messages.length; i++)
18
+ keep.add(i);
19
+ return keep;
20
+ }
21
+ export function compactMessages(messages, budget = DEFAULT_CONTEXT_CHARS) {
22
+ if (transcriptChars(messages) <= budget)
23
+ return messages;
24
+ const keep = protectedRange(messages);
25
+ const out = messages.map((m) => ({ ...m }));
26
+ for (let i = 0; i < out.length && transcriptChars(out) > budget; i++) {
27
+ if (keep.has(i) || out[i].role !== 'tool')
28
+ continue;
29
+ out[i] = { ...out[i], content: '[context pruned: earlier tool output removed to fit budget]' };
30
+ }
31
+ return out;
32
+ }
@@ -0,0 +1,8 @@
1
+ export * from './types.ts';
2
+ export * from './registry.ts';
3
+ export * from './provider.ts';
4
+ export * from './loop.ts';
5
+ export * from './session.ts';
6
+ export * from './config.ts';
7
+ export * from './context.ts';
8
+ export * from './mcp.ts';
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ export * from "./types.js";
2
+ export * from "./registry.js";
3
+ export * from "./provider.js";
4
+ export * from "./loop.js";
5
+ export * from "./session.js";
6
+ export * from "./config.js";
7
+ export * from "./context.js";
8
+ export * from "./mcp.js";
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @hmharness/kernel - loop-types
3
+ * Structural typing for the registry the loop needs (avoids a hard import
4
+ * cycle and lets tests pass a stub registry).
5
+ */
6
+ import type { Tool } from './types.ts';
7
+ export interface ChatMessage {
8
+ role: 'system' | 'user' | 'assistant' | 'tool';
9
+ content: string | null;
10
+ tool_calls?: Array<{
11
+ id: string;
12
+ type: 'function';
13
+ function: {
14
+ name: string;
15
+ arguments: string;
16
+ };
17
+ }>;
18
+ tool_call_id?: string;
19
+ name?: string;
20
+ }
21
+ export interface RegistryLike {
22
+ get(name: string): Tool | undefined;
23
+ toOpenAITools(): Array<{
24
+ type: 'function';
25
+ function: {
26
+ name: string;
27
+ description: string;
28
+ parameters: unknown;
29
+ };
30
+ }>;
31
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/loop.d.ts ADDED
@@ -0,0 +1,39 @@
1
+ import type { ChatMessage, RegistryLike } from './loop-types.ts';
2
+ import { chat, type DeltaKind } from './provider.ts';
3
+ import type { ProviderConfig, ToolContext } from './types.ts';
4
+ export interface LoopEvents {
5
+ onAssistant?(m: ChatMessage): void;
6
+ onDelta?(kind: DeltaKind, chunk: string): void;
7
+ onToolCall?(name: string, args: Record<string, unknown>): void;
8
+ onToolResult?(name: string, output: string, isError: boolean): void;
9
+ /** Called when a tool requested approval. granted=false means denied. */
10
+ onApproval?(name: string, args: Record<string, unknown>, granted: boolean): void;
11
+ onFinal?(text: string, turns: number): void;
12
+ }
13
+ export interface LoopApproval {
14
+ ask(toolName: string, args: Record<string, unknown>): Promise<boolean>;
15
+ }
16
+ export interface LoopResult {
17
+ text: string;
18
+ turns: number;
19
+ toolUses: number;
20
+ /** The full working transcript (system + task + all turns), uncompacted. */
21
+ messages: ChatMessage[];
22
+ /** Token usage summed across all model calls in this run (when reported). */
23
+ usage: {
24
+ promptTokens: number;
25
+ completionTokens: number;
26
+ };
27
+ }
28
+ export declare function runLoop(opts: {
29
+ provider: ProviderConfig;
30
+ registry: RegistryLike;
31
+ messages: ChatMessage[];
32
+ ctx: ToolContext;
33
+ maxTurns?: number;
34
+ maxContextChars?: number;
35
+ approval?: LoopApproval;
36
+ events?: LoopEvents;
37
+ /** Injectable model call (tests pass a fake; production uses provider.chat). */
38
+ chatImpl?: typeof chat;
39
+ }): Promise<LoopResult>;
package/dist/loop.js ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * @hmharness/kernel - loop
3
+ * The agent loop: call the model, run the tools it asks for, feed results
4
+ * back, repeat until it answers without tool calls or the turn budget is
5
+ * spent. This is the "loop engineering" core - kept deliberately dull.
6
+ * Tools marked needsApproval pause here for a caller-provided ask() gate;
7
+ * no gate configured means deny (safe default). Between turns the
8
+ * transcript is compacted against the context budget.
9
+ */
10
+ import { compactMessages } from "./context.js";
11
+ import { chat } from "./provider.js";
12
+ export async function runLoop(opts) {
13
+ const { provider, registry, ctx, events } = opts;
14
+ const modelCall = opts.chatImpl ?? chat;
15
+ const maxTurns = opts.maxTurns ?? 25;
16
+ const working = [...opts.messages];
17
+ let toolUses = 0;
18
+ const usage = { promptTokens: 0, completionTokens: 0 };
19
+ const tools = registry.toOpenAITools();
20
+ for (let turn = 1; turn <= maxTurns; turn++) {
21
+ const chatRes = await modelCall(provider, compactMessages(working, opts.maxContextChars), tools, {
22
+ onDelta: events?.onDelta,
23
+ });
24
+ usage.promptTokens += chatRes.usage?.prompt_tokens ?? 0;
25
+ usage.completionTokens += chatRes.usage?.completion_tokens ?? 0;
26
+ const { message } = chatRes;
27
+ events?.onAssistant?.(message);
28
+ const calls = message.tool_calls ?? [];
29
+ if (calls.length === 0) {
30
+ const text = message.content ?? '';
31
+ events?.onFinal?.(text, turn);
32
+ return { text, turns: turn, toolUses, messages: working, usage };
33
+ }
34
+ working.push({ role: 'assistant', content: message.content ?? null, tool_calls: calls });
35
+ const planned = [];
36
+ for (const call of calls) {
37
+ const name = call.function.name;
38
+ let args = {};
39
+ let badArgs = false;
40
+ try {
41
+ args = call.function.arguments ? JSON.parse(call.function.arguments) : {};
42
+ }
43
+ catch {
44
+ badArgs = true;
45
+ }
46
+ events?.onToolCall?.(name, args);
47
+ const tool = registry.get(name);
48
+ const p = { call, name, args, output: '', isError: false, skip: false };
49
+ if (!tool) {
50
+ p.output = `unknown tool: ${name}`;
51
+ p.isError = true;
52
+ p.skip = true;
53
+ }
54
+ else if (badArgs) {
55
+ p.output = `unparseable tool arguments for ${name}: ${call.function.arguments.slice(0, 200)}`;
56
+ p.isError = true;
57
+ p.skip = true;
58
+ }
59
+ else if (tool.needsApproval?.(args)) {
60
+ // Safe default: with no gate wired in, risky tools are denied.
61
+ const granted = opts.approval ? await opts.approval.ask(name, args) : false;
62
+ events?.onApproval?.(name, args, granted);
63
+ if (!granted) {
64
+ p.output = 'User declined this action. Ask how to proceed or find a non-destructive alternative.';
65
+ p.isError = true;
66
+ p.skip = true;
67
+ }
68
+ }
69
+ planned.push(p);
70
+ }
71
+ await Promise.all(planned.map(async (p) => {
72
+ if (p.skip)
73
+ return;
74
+ try {
75
+ const r = await registry.get(p.name).execute(p.args, ctx);
76
+ p.output = r.output;
77
+ p.isError = r.isError === true;
78
+ }
79
+ catch (err) {
80
+ p.output = String(err);
81
+ p.isError = true;
82
+ }
83
+ }));
84
+ for (const p of planned) {
85
+ toolUses++;
86
+ events?.onToolResult?.(p.name, p.output, p.isError);
87
+ working.push({
88
+ role: 'tool',
89
+ tool_call_id: p.call.id,
90
+ name: p.name,
91
+ content: p.output.length > 60_000 ? p.output.slice(0, 60_000) + '\n...[truncated]' : p.output,
92
+ });
93
+ }
94
+ }
95
+ const text = `Turn budget exhausted (${maxTurns}). Last state preserved in the session log.`;
96
+ events?.onFinal?.(text, maxTurns);
97
+ return { text, turns: maxTurns, toolUses, messages: working, usage };
98
+ }
package/dist/mcp.d.ts ADDED
@@ -0,0 +1,55 @@
1
+ import type { Tool } from './types.ts';
2
+ export type McpServerConfig = {
3
+ type: 'stdio';
4
+ command: string;
5
+ args?: string[];
6
+ env?: Record<string, string>;
7
+ trusted?: boolean;
8
+ } | {
9
+ type: 'http';
10
+ url: string;
11
+ headers?: Record<string, string>;
12
+ trusted?: boolean;
13
+ };
14
+ export declare class McpClient {
15
+ readonly serverName: string;
16
+ readonly config: McpServerConfig;
17
+ private proc;
18
+ private sessionId;
19
+ private buffer;
20
+ private stderrTail;
21
+ private pending;
22
+ private ready;
23
+ constructor(serverName: string, config: McpServerConfig);
24
+ /** initialize handshake. Must be called exactly once before use. */
25
+ connect(timeoutMs?: number): Promise<void>;
26
+ /** Fire-and-forget notification over HTTP (server answers 202). */
27
+ private notify;
28
+ private spawnStdio;
29
+ private onStdioChunk;
30
+ private onMessage;
31
+ /** One JSON-RPC round trip over whichever transport this server uses. */
32
+ private request;
33
+ /** Read an SSE body up to the first `data:` JSON message, then stop. */
34
+ private firstSseMessage;
35
+ listTools(): Promise<Array<{
36
+ name: string;
37
+ description?: string;
38
+ inputSchema?: unknown;
39
+ }>>;
40
+ callTool(name: string, args: Record<string, unknown>, timeoutMs?: number): Promise<{
41
+ output: string;
42
+ isError: boolean;
43
+ }>;
44
+ close(): void;
45
+ }
46
+ /** OpenAI function-name charset; MCP allows dots/dashes which it forbids. */
47
+ export declare function sanitizeToolName(name: string): string;
48
+ /**
49
+ * Connect + list + project. Separate from projectMcpTools so callers can
50
+ * distinguish "server down" from "server has no tools".
51
+ */
52
+ export declare function mcpServerTools(serverName: string, config: McpServerConfig): Promise<{
53
+ client: McpClient;
54
+ tools: Tool[];
55
+ }>;