@hmharness/kernel 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.d.ts CHANGED
@@ -28,6 +28,47 @@ export declare function addProviders(items: Array<{
28
28
  cfg: HmhConfig;
29
29
  added: string[];
30
30
  }>;
31
+ /**
32
+ * Insert or fully replace one provider (web settings center: "保存即生效").
33
+ * apiKey omitted/empty keeps the existing key — the UI never round-trips
34
+ * secrets. Returns the refreshed config; throws on bad names.
35
+ */
36
+ export declare function upsertProvider(input: {
37
+ name: string;
38
+ baseUrl: string;
39
+ model: string;
40
+ apiKey?: string;
41
+ purposes?: string[];
42
+ }): Promise<HmhConfig>;
43
+ /**
44
+ * Save (upsert) one named provider for the web/TUI settings center.
45
+ * Semantics differ from upsertProvider on the secret key: `apiKey ===
46
+ * undefined` (form field left blank) KEEPS the existing key, while
47
+ * `apiKey === ''` DELETES it. Optional fields (authHeader, timeoutMs,
48
+ * contextWindow, supportsVision) are only written when provided. Returns
49
+ * the refreshed config; throws on invalid names/baseUrl/model.
50
+ */
51
+ export declare function saveProvider(name: string, p: {
52
+ baseUrl: string;
53
+ model: string;
54
+ apiKey?: string;
55
+ authHeader?: string;
56
+ timeoutMs?: number;
57
+ contextWindow?: number;
58
+ supportsVision?: boolean;
59
+ }): Promise<HmhConfig>;
60
+ /** Remove a provider (web settings center). Routing keys that pointed at it
61
+ * (chat/vision/evolve/bench) are removed with it instead of being left
62
+ * dangling. Returns the refreshed config. */
63
+ export declare function deleteProvider(name: string): Promise<HmhConfig>;
64
+ /**
65
+ * Generic in-place config patch (web settings center): read the raw JSON,
66
+ * Object.assign the top-level keys, shallow-merge `evolution` separately,
67
+ * write back and return loadConfig(). The CALLER is responsible for only
68
+ * passing whitelisted keys (this function validates nothing). `theme` is a
69
+ * plain top-level key and flows through the top-level assign.
70
+ */
71
+ export declare function patchConfig(partial: Record<string, unknown>): Promise<HmhConfig>;
31
72
  export declare function initHome(): Promise<{
32
73
  home: string;
33
74
  created: string[];
package/dist/config.js CHANGED
@@ -105,6 +105,143 @@ export async function addProviders(items) {
105
105
  await writeFile(file, JSON.stringify(raw, null, 2) + '\n', 'utf8');
106
106
  return { cfg: await loadConfig(), added };
107
107
  }
108
+ /**
109
+ * Insert or fully replace one provider (web settings center: "保存即生效").
110
+ * apiKey omitted/empty keeps the existing key — the UI never round-trips
111
+ * secrets. Returns the refreshed config; throws on bad names.
112
+ */
113
+ export async function upsertProvider(input) {
114
+ const name = String(input.name ?? '').trim();
115
+ const baseUrl = String(input.baseUrl ?? '').trim().replace(/\/+$/, '');
116
+ const model = String(input.model ?? '').trim();
117
+ if (!/^[a-zA-Z0-9_-]{1,48}$/.test(name))
118
+ throw new Error('provider name must be 1-48 chars of [a-zA-Z0-9_-]');
119
+ if (!/^https?:\/\//.test(baseUrl))
120
+ throw new Error('baseUrl must start with http(s)://');
121
+ if (!model)
122
+ throw new Error('model required');
123
+ const file = join(homeDir(), 'config.json');
124
+ let raw = {};
125
+ try {
126
+ raw = JSON.parse(await readFile(file, 'utf8'));
127
+ }
128
+ catch {
129
+ /* fresh config */
130
+ }
131
+ const providers = (raw.providers ?? {});
132
+ const existing = (providers[name] ?? {});
133
+ const next = {
134
+ baseUrl,
135
+ model,
136
+ // empty apiKey in the form = keep whatever was configured (never blank a key)
137
+ ...(input.apiKey ? { apiKey: input.apiKey } : existing.apiKey ? { apiKey: existing.apiKey } : {}),
138
+ ...(existing.authHeader ? { authHeader: existing.authHeader } : {}),
139
+ ...(existing.timeoutMs ? { timeoutMs: existing.timeoutMs } : {}),
140
+ ...(existing.supportsVision ? { supportsVision: existing.supportsVision } : {}),
141
+ ...(input.purposes?.length ? { purposes: input.purposes } : existing.purposes ? { purposes: existing.purposes } : {}),
142
+ };
143
+ providers[name] = next;
144
+ raw.providers = providers;
145
+ await writeFile(file, JSON.stringify(raw, null, 2) + '\n', 'utf8');
146
+ return loadConfig();
147
+ }
148
+ /**
149
+ * Save (upsert) one named provider for the web/TUI settings center.
150
+ * Semantics differ from upsertProvider on the secret key: `apiKey ===
151
+ * undefined` (form field left blank) KEEPS the existing key, while
152
+ * `apiKey === ''` DELETES it. Optional fields (authHeader, timeoutMs,
153
+ * contextWindow, supportsVision) are only written when provided. Returns
154
+ * the refreshed config; throws on invalid names/baseUrl/model.
155
+ */
156
+ export async function saveProvider(name, p) {
157
+ if (!name || !/^[a-zA-Z0-9_-]+$/.test(name))
158
+ throw new Error('invalid provider name');
159
+ if (!/^https?:\/\//.test(p.baseUrl))
160
+ throw new Error('baseUrl must start with http:// or https://');
161
+ if (!p.model)
162
+ throw new Error('model must not be empty');
163
+ const file = join(homeDir(), 'config.json');
164
+ let raw = {};
165
+ try {
166
+ raw = JSON.parse(await readFile(file, 'utf8'));
167
+ }
168
+ catch {
169
+ /* fresh config */
170
+ }
171
+ const providers = (raw.providers ?? {});
172
+ const existing = (providers[name] ?? {});
173
+ const next = {
174
+ baseUrl: p.baseUrl,
175
+ model: p.model,
176
+ // apiKey === undefined: keep the existing key (edit form left blank).
177
+ // apiKey === '': explicitly clear it (delete the key).
178
+ ...(p.apiKey !== undefined
179
+ ? (p.apiKey !== '' ? { apiKey: p.apiKey } : {})
180
+ : existing.apiKey !== undefined ? { apiKey: existing.apiKey } : {}),
181
+ ...(p.authHeader ? { authHeader: p.authHeader } : {}),
182
+ ...(p.timeoutMs !== undefined ? { timeoutMs: p.timeoutMs } : {}),
183
+ ...(p.contextWindow !== undefined ? { contextWindow: p.contextWindow } : {}),
184
+ ...(p.supportsVision !== undefined ? { supportsVision: p.supportsVision } : {}),
185
+ };
186
+ providers[name] = next;
187
+ raw.providers = providers;
188
+ await writeFile(file, JSON.stringify(raw, null, 2) + '\n', 'utf8');
189
+ return loadConfig();
190
+ }
191
+ /** Remove a provider (web settings center). Routing keys that pointed at it
192
+ * (chat/vision/evolve/bench) are removed with it instead of being left
193
+ * dangling. Returns the refreshed config. */
194
+ export async function deleteProvider(name) {
195
+ const file = join(homeDir(), 'config.json');
196
+ let raw = {};
197
+ try {
198
+ raw = JSON.parse(await readFile(file, 'utf8'));
199
+ }
200
+ catch {
201
+ /* fresh config */
202
+ }
203
+ const providers = (raw.providers ?? {});
204
+ if (!(name in providers))
205
+ throw new Error(`unknown provider "${name}"`);
206
+ delete providers[name];
207
+ raw.providers = providers;
208
+ const routing = (raw.routing ?? {});
209
+ let routingTouched = false;
210
+ for (const key of ['chat', 'vision', 'evolve', 'bench']) {
211
+ if (routing[key] === name) {
212
+ delete routing[key];
213
+ routingTouched = true;
214
+ }
215
+ }
216
+ if (routingTouched)
217
+ raw.routing = routing;
218
+ await writeFile(file, JSON.stringify(raw, null, 2) + '\n', 'utf8');
219
+ return loadConfig();
220
+ }
221
+ /**
222
+ * Generic in-place config patch (web settings center): read the raw JSON,
223
+ * Object.assign the top-level keys, shallow-merge `evolution` separately,
224
+ * write back and return loadConfig(). The CALLER is responsible for only
225
+ * passing whitelisted keys (this function validates nothing). `theme` is a
226
+ * plain top-level key and flows through the top-level assign.
227
+ */
228
+ export async function patchConfig(partial) {
229
+ const file = join(homeDir(), 'config.json');
230
+ let raw = {};
231
+ try {
232
+ raw = JSON.parse(await readFile(file, 'utf8'));
233
+ }
234
+ catch {
235
+ /* fresh config */
236
+ }
237
+ const { evolution, ...top } = partial;
238
+ Object.assign(raw, top);
239
+ if (evolution !== undefined && typeof evolution === 'object' && evolution !== null) {
240
+ raw.evolution = { ...(raw.evolution ?? {}), ...evolution };
241
+ }
242
+ await writeFile(file, JSON.stringify(raw, null, 2) + '\n', 'utf8');
243
+ return loadConfig();
244
+ }
108
245
  export async function initHome() {
109
246
  const home = homeDir();
110
247
  const created = [];
package/dist/goal.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ export type GoalStore = {
2
+ [sessionId: string]: {
3
+ goal: string;
4
+ time: string;
5
+ };
6
+ };
7
+ /** The persisted goal for one session, or null when unset/unreadable. */
8
+ export declare function getGoal(home: string, sessionId: string): Promise<string | null>;
9
+ /** Persist (or, with an empty goal, clear) one session's goal. */
10
+ export declare function setGoal(home: string, sessionId: string, goal: string): Promise<void>;
11
+ /** Remove one session's goal (no-op when it has none). */
12
+ export declare function clearGoal(home: string, sessionId: string): Promise<void>;
package/dist/goal.js ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * @hmharness/kernel - goal
3
+ * Session-level goal store: HMH_HOME/goals.json maps sessionId -> goal
4
+ * ({ goal, time }). Web/TUI persist a goal when a session starts (or a run
5
+ * is forked); the agent runner reads it so the goal survives across turns,
6
+ * resumes and restarts. All IO is plain readFile/writeFile - a corrupt file
7
+ * degrades to null / an empty store rebuilt on the next write, never throws.
8
+ */
9
+ import { readFile, writeFile } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ async function readStore(file) {
12
+ try {
13
+ const parsed = JSON.parse(await readFile(file, 'utf8'));
14
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
15
+ ? parsed
16
+ : {};
17
+ }
18
+ catch {
19
+ // missing or corrupt file = empty store (rebuilt on the next write)
20
+ return {};
21
+ }
22
+ }
23
+ /** The persisted goal for one session, or null when unset/unreadable. */
24
+ export async function getGoal(home, sessionId) {
25
+ const store = await readStore(join(home, 'goals.json'));
26
+ const entry = store[sessionId];
27
+ return typeof entry?.goal === 'string' ? entry.goal : null;
28
+ }
29
+ /** Persist (or, with an empty goal, clear) one session's goal. */
30
+ export async function setGoal(home, sessionId, goal) {
31
+ const file = join(home, 'goals.json');
32
+ const store = await readStore(file);
33
+ if (!goal) {
34
+ delete store[sessionId];
35
+ }
36
+ else {
37
+ store[sessionId] = { goal, time: new Date().toISOString() };
38
+ }
39
+ await writeFile(file, JSON.stringify(store, null, 2) + '\n', 'utf8');
40
+ }
41
+ /** Remove one session's goal (no-op when it has none). */
42
+ export async function clearGoal(home, sessionId) {
43
+ const file = join(home, 'goals.json');
44
+ const store = await readStore(file);
45
+ delete store[sessionId];
46
+ await writeFile(file, JSON.stringify(store, null, 2) + '\n', 'utf8');
47
+ }
package/dist/index.d.ts CHANGED
@@ -9,3 +9,4 @@ export * from './router.ts';
9
9
  export * from './config.ts';
10
10
  export * from './context.ts';
11
11
  export * from './mcp.ts';
12
+ export * from './goal.ts';
package/dist/index.js CHANGED
@@ -9,3 +9,4 @@ export * from "./router.js";
9
9
  export * from "./config.js";
10
10
  export * from "./context.js";
11
11
  export * from "./mcp.js";
12
+ export * from "./goal.js";
package/dist/loop.d.ts CHANGED
@@ -8,6 +8,10 @@ export interface LoopEvents {
8
8
  onToolResult?(name: string, output: string, isError: boolean): void;
9
9
  /** Called when a tool requested approval. granted=false means denied. */
10
10
  onApproval?(name: string, args: Record<string, unknown>, granted: boolean): void;
11
+ /** Fired when an injected user message entered the working transcript
12
+ * (web Ctrl+Enter / TUI Enter-while-running). The message is appended
13
+ * after the previous tool batch, before the next model call. */
14
+ onInjected?(message: string): void;
11
15
  onFinal?(text: string, turns: number): void;
12
16
  }
13
17
  export interface LoopApproval {
@@ -61,4 +65,18 @@ export declare function runLoop(opts: {
61
65
  maxIdleTurns?: number;
62
66
  /** Hard total token spend cap (default: 50M — generous enough for days). */
63
67
  maxTotalTokens?: number;
68
+ /** Injected user messages (web Ctrl+Enter / TUI Enter-while-running):
69
+ * called after every tool-result batch; the returned string (or null)
70
+ * is appended to the working transcript as a user message before the
71
+ * next model call. Absent -> no injection support (codex-style runtime
72
+ * steering disabled). */
73
+ injectQueue?: () => string | null;
74
+ /** Polled at every turn boundary (and before the first model call): user
75
+ * texts injected into the RUNNING loop (codex Enter-inject semantics).
76
+ * poll() drains its pending queue; each entry becomes a user turn. */
77
+ injections?: {
78
+ poll(): Array<{
79
+ text: string;
80
+ }>;
81
+ };
64
82
  }): Promise<LoopResult>;
package/dist/loop.js CHANGED
@@ -47,6 +47,14 @@ export async function runLoop(opts) {
47
47
  reason = 'interrupted';
48
48
  break;
49
49
  }
50
+ // Mid-run injection drain (codex Enter-inject semantics): entries queued
51
+ // while the previous round's model request / tool batch was in flight
52
+ // join the transcript here, so the NEXT model call sees them. An
53
+ // injection never affects a request already issued this round.
54
+ for (const inj of opts.injections?.poll() ?? []) {
55
+ working.push({ role: 'user', content: inj.text });
56
+ events?.onInjected?.(inj.text);
57
+ }
50
58
  const compacted = opts.summarizeContext
51
59
  ? await compactWithDigest(working, budget, opts.summarizeContext)
52
60
  : compactMessages(working, budget);
@@ -89,6 +97,15 @@ export async function runLoop(opts) {
89
97
  const calls = message.tool_calls ?? [];
90
98
  if (calls.length === 0) {
91
99
  const text = message.content ?? '';
100
+ // A runtime-steering injection may have arrived while this turn was in
101
+ // flight; honor it by continuing instead of ending (codex Enter while
102
+ // the last answer streams). Only end when the queue is empty.
103
+ const pending = opts.injectQueue?.();
104
+ if (pending) {
105
+ working.push({ role: 'user', content: pending });
106
+ events?.onInjected?.(pending);
107
+ continue;
108
+ }
92
109
  events?.onFinal?.(text, turn);
93
110
  return { text, turns: turn, toolUses, messages: working, usage, reason: 'final' };
94
111
  }
@@ -152,6 +169,14 @@ export async function runLoop(opts) {
152
169
  content: p.output.length > 60_000 ? p.output.slice(0, 60_000) + '\n...[truncated]' : p.output,
153
170
  });
154
171
  }
172
+ // Runtime steering (codex Enter-while-running): a user message injected
173
+ // during the tool batch joins the transcript here, so the NEXT model call
174
+ // already sees it. Multiple pushes are drained in FIFO order.
175
+ const injected = opts.injectQueue?.();
176
+ if (injected) {
177
+ working.push({ role: 'user', content: injected });
178
+ events?.onInjected?.(injected);
179
+ }
155
180
  // Idle detection: count consecutive turns where NO tool succeeded. A
156
181
  // productive agent always has at least one successful call; N consecutive
157
182
  // all-fail/all-skip turns = the agent is stuck in a loop (this replaces
package/dist/types.d.ts CHANGED
@@ -101,6 +101,8 @@ export interface HmhConfig {
101
101
  visionFallbacks?: ProviderConfig[];
102
102
  /** UI + system-prompt language. Default 'zh'. */
103
103
  locale?: 'zh' | 'en';
104
+ /** UI theme preference: 'dark' (default) | 'light' | 'system'. */
105
+ theme?: 'dark' | 'light' | 'system';
104
106
  /** Run a background evolution cycle after every N recorded insights
105
107
  * (default 3; 0 disables). Tier 3 of the feedback ladder: Tier 1 = raw
106
108
  * error self-notes (every task, zero cost), Tier 2 = one model-call
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hmharness/kernel",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "hmharness kernel: tool registry, provider adapters, the agent loop, session log, config. Zero runtime dependencies (Node >=22 native fetch).",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",