@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 +41 -0
- package/dist/config.js +137 -0
- package/dist/goal.d.ts +12 -0
- package/dist/goal.js +47 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/loop.d.ts +18 -0
- package/dist/loop.js +25 -0
- package/dist/types.d.ts +2 -0
- package/package.json +1 -1
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
package/dist/index.js
CHANGED
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.
|
|
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",
|