@hmharness/kernel 0.3.0 → 0.4.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.
package/dist/context.d.ts CHANGED
@@ -3,9 +3,36 @@
3
3
  * Char-budget context compaction. Long agent runs are dominated by stale
4
4
  * tool output; when the transcript exceeds its budget we replace the oldest
5
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.
6
+ * recent tail) with a tombstone. Deterministic baseline, no model call.
7
+ *
8
+ * Rolling digest (model-aware context engineering, part 3): with a
9
+ * `summarize` hook, evicted content is first distilled into a persistent
10
+ * "[rolling digest]" system note that survives future compactions and
11
+ * merges with prior digests - the conversation's past shrinks semantically
12
+ * instead of being dropped. Without the hook, behaviour is byte-identical
13
+ * to the legacy prune.
7
14
  */
8
15
  import type { ChatMessage } from './types.ts';
9
16
  export declare const DEFAULT_CONTEXT_CHARS = 160000;
10
17
  export declare function transcriptChars(messages: ChatMessage[]): number;
18
+ export declare const DIGEST_MARK = "[rolling digest of earlier context]";
11
19
  export declare function compactMessages(messages: ChatMessage[], budget?: number): ChatMessage[];
20
+ export interface CompactResult {
21
+ messages: ChatMessage[];
22
+ /** Chars of tool output actually evicted this pass (0 = no compaction). */
23
+ evictedChars: number;
24
+ }
25
+ /** Deterministic prune + tombstone; reports what was evicted so callers can
26
+ * feed it to a summarizer. */
27
+ export declare function compactWithEvictions(messages: ChatMessage[], budget?: number): CompactResult;
28
+ /**
29
+ * Compact with a rolling digest. The digest is ONE system note placed right
30
+ * after the first user message (inside the protected head), so it survives
31
+ * every future compaction. Each pass merges: summarize(previous digest body
32
+ * + newly evicted tool outputs). Summarizer failures degrade silently to
33
+ * the deterministic prune (the digest just stops growing).
34
+ */
35
+ export declare function compactWithDigest(messages: ChatMessage[], budget: number, summarize: (input: {
36
+ previousDigest: string | null;
37
+ evicted: string[];
38
+ }) => Promise<string>): Promise<ChatMessage[]>;
package/dist/context.js CHANGED
@@ -18,6 +18,10 @@ function protectedRange(messages) {
18
18
  keep.add(i);
19
19
  return keep;
20
20
  }
21
+ export const DIGEST_MARK = '[rolling digest of earlier context]';
22
+ function findDigest(messages) {
23
+ return messages.findIndex((m) => m.role === 'system' && typeof m.content === 'string' && m.content.startsWith(DIGEST_MARK));
24
+ }
21
25
  export function compactMessages(messages, budget = DEFAULT_CONTEXT_CHARS) {
22
26
  if (transcriptChars(messages) <= budget)
23
27
  return messages;
@@ -30,3 +34,61 @@ export function compactMessages(messages, budget = DEFAULT_CONTEXT_CHARS) {
30
34
  }
31
35
  return out;
32
36
  }
37
+ /** Deterministic prune + tombstone; reports what was evicted so callers can
38
+ * feed it to a summarizer. */
39
+ export function compactWithEvictions(messages, budget = DEFAULT_CONTEXT_CHARS) {
40
+ if (transcriptChars(messages) <= budget)
41
+ return { messages, evictedChars: 0 };
42
+ const keep = protectedRange(messages);
43
+ const out = messages.map((m) => ({ ...m }));
44
+ let evicted = 0;
45
+ for (let i = 0; i < out.length && transcriptChars(out) > budget; i++) {
46
+ if (keep.has(i) || out[i].role !== 'tool')
47
+ continue;
48
+ evicted += out[i].content?.length ?? 0;
49
+ out[i] = { ...out[i], content: '[context pruned: earlier tool output removed to fit budget]' };
50
+ }
51
+ return { messages: out, evictedChars: evicted };
52
+ }
53
+ /**
54
+ * Compact with a rolling digest. The digest is ONE system note placed right
55
+ * after the first user message (inside the protected head), so it survives
56
+ * every future compaction. Each pass merges: summarize(previous digest body
57
+ * + newly evicted tool outputs). Summarizer failures degrade silently to
58
+ * the deterministic prune (the digest just stops growing).
59
+ */
60
+ export async function compactWithDigest(messages, budget, summarize) {
61
+ const { messages: pruned, evictedChars } = compactWithEvictions(messages, budget);
62
+ if (evictedChars === 0)
63
+ return pruned;
64
+ const digestIdx = findDigest(pruned);
65
+ const previousDigest = digestIdx >= 0 ? (pruned[digestIdx].content ?? '').slice(DIGEST_MARK.length).trim() : null;
66
+ const evicted = [];
67
+ // what was evicted = tombstoned positions vs the input (compare by index)
68
+ for (let i = 0; i < pruned.length; i++) {
69
+ if (pruned[i].role === 'tool' && pruned[i].content === '[context pruned: earlier tool output removed to fit budget]'
70
+ && messages[i]?.role === 'tool' && messages[i].content !== pruned[i].content) {
71
+ evicted.push(String(messages[i].content).slice(0, 4000));
72
+ }
73
+ }
74
+ if (evicted.length === 0 && previousDigest)
75
+ return pruned; // nothing new to distill
76
+ let body;
77
+ try {
78
+ body = (await summarize({ previousDigest, evicted })).trim().slice(0, 12_000);
79
+ }
80
+ catch {
81
+ return pruned; // summarizer down -> keep deterministic result
82
+ }
83
+ if (!body)
84
+ return pruned;
85
+ const note = { role: 'system', content: `${DIGEST_MARK}\n${body}` };
86
+ if (digestIdx >= 0) {
87
+ const out = pruned.map((m, i) => (i === digestIdx ? note : m));
88
+ return out;
89
+ }
90
+ // insert after the first user message (protected head)
91
+ const firstUser = pruned.findIndex((m) => m.role === 'user');
92
+ const at = firstUser >= 0 ? firstUser + 1 : pruned.length;
93
+ return [...pruned.slice(0, at), note, ...pruned.slice(at)];
94
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from './types.ts';
2
2
  export * from './registry.ts';
3
3
  export * from './provider.ts';
4
+ export * from './window.ts';
4
5
  export * from './loop.ts';
5
6
  export * from './session.ts';
6
7
  export * from './config.ts';
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from "./types.js";
2
2
  export * from "./registry.js";
3
3
  export * from "./provider.js";
4
+ export * from "./window.js";
4
5
  export * from "./loop.js";
5
6
  export * from "./session.js";
6
7
  export * from "./config.js";
package/dist/loop.d.ts CHANGED
@@ -31,9 +31,18 @@ export declare function runLoop(opts: {
31
31
  messages: ChatMessage[];
32
32
  ctx: ToolContext;
33
33
  maxTurns?: number;
34
+ /** Explicit transcript budget override (chars). Default: scales with the
35
+ * model's context window (window.ts registry / provider.contextWindow). */
34
36
  maxContextChars?: number;
35
37
  approval?: LoopApproval;
36
38
  events?: LoopEvents;
37
39
  /** Injectable model call (tests pass a fake; production uses provider.chat). */
38
40
  chatImpl?: typeof chat;
41
+ /** Rolling digest hook (model-aware context engineering): when compaction
42
+ * evicts content, it is distilled into a persistent digest note instead
43
+ * of being dropped. Absent -> deterministic prune only. */
44
+ summarizeContext?: (input: {
45
+ previousDigest: string | null;
46
+ evicted: string[];
47
+ }) => Promise<string>;
39
48
  }): Promise<LoopResult>;
package/dist/loop.js CHANGED
@@ -7,18 +7,23 @@
7
7
  * no gate configured means deny (safe default). Between turns the
8
8
  * transcript is compacted against the context budget.
9
9
  */
10
- import { compactMessages } from "./context.js";
10
+ import { compactMessages, compactWithDigest } from "./context.js";
11
+ import { adaptiveContextChars } from "./window.js";
11
12
  import { chat } from "./provider.js";
12
13
  export async function runLoop(opts) {
13
14
  const { provider, registry, ctx, events } = opts;
14
15
  const modelCall = opts.chatImpl ?? chat;
15
16
  const maxTurns = opts.maxTurns ?? 25;
17
+ const budget = opts.maxContextChars ?? adaptiveContextChars(provider);
16
18
  const working = [...opts.messages];
17
19
  let toolUses = 0;
18
20
  const usage = { promptTokens: 0, completionTokens: 0 };
19
21
  const tools = registry.toOpenAITools();
20
22
  for (let turn = 1; turn <= maxTurns; turn++) {
21
- const chatRes = await modelCall(provider, compactMessages(working, opts.maxContextChars), tools, {
23
+ const compacted = opts.summarizeContext
24
+ ? await compactWithDigest(working, budget, opts.summarizeContext)
25
+ : compactMessages(working, budget);
26
+ const chatRes = await modelCall(provider, compacted, tools, {
22
27
  onDelta: events?.onDelta,
23
28
  });
24
29
  usage.promptTokens += chatRes.usage?.prompt_tokens ?? 0;
package/dist/types.d.ts CHANGED
@@ -64,6 +64,10 @@ export interface ProviderConfig {
64
64
  * tokenrouter took 84s on an evolve-sized prompt vs the 120s default)
65
65
  * set e.g. 240000 on the evolve/bench routes. */
66
66
  timeoutMs?: number;
67
+ /** Explicit context window (tokens) for this model. Overrides the
68
+ * built-in registry; the transcript budget then scales to the window
69
+ * (see window.ts) instead of the fixed legacy default. */
70
+ contextWindow?: number;
67
71
  }
68
72
  /** User-level configuration (HMH_HOME/config.json). */
69
73
  export interface HmhConfig {
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @hmharness/kernel - window (model-aware context engineering, part 1)
3
+ * The context budget used to be one fixed char number for every model - a
4
+ * 128K-window flash model and a 1M-window model got the same 160K chars
5
+ * (small windows overflow, huge windows waste). This registry maps model
6
+ * names to their context windows; budgets then scale with the window.
7
+ *
8
+ * Layered resolution (first hit wins):
9
+ * 1. ProviderConfig.contextWindow (explicit, tokens) - the user is right
10
+ * 2. registry pattern match on the model name - conservative values
11
+ * 3. unknown -> null (callers fall back to the legacy fixed default)
12
+ *
13
+ * Registry values are deliberately CONSERVATIVE (never the vendor max):
14
+ * a wrong-low budget costs a bit of context; a wrong-high one costs the run.
15
+ */
16
+ export interface ContextWindowInfo {
17
+ windowTokens: number | null;
18
+ source: 'config' | 'registry' | 'unknown';
19
+ }
20
+ export declare function contextWindowFor(p: {
21
+ model: string;
22
+ contextWindow?: number;
23
+ }): ContextWindowInfo;
24
+ /** Transcript char budget for a window: ~2.5 chars/token mixed-language
25
+ * (ASCII ~4, CJK ~1.2), and only HALF the window may be history - the rest
26
+ * belongs to system prompt, injected memory/skills, tool schemas and the
27
+ * reply. 128K tokens -> 160K chars, which is exactly the legacy default -
28
+ * known-window models scale from there, unknown models keep today's
29
+ * behaviour unchanged. */
30
+ export declare const CHARS_PER_TOKEN = 2.5;
31
+ export declare const HISTORY_WINDOW_FRACTION = 0.5;
32
+ export declare const MIN_CONTEXT_CHARS = 40000;
33
+ export declare const MAX_CONTEXT_CHARS = 1000000;
34
+ export declare const LEGACY_DEFAULT_CONTEXT_CHARS = 160000;
35
+ export declare function contextBudgetChars(windowTokens: number | null): number;
36
+ /** One-call helper: provider -> adaptive transcript char budget. */
37
+ export declare function adaptiveContextChars(p: {
38
+ model: string;
39
+ contextWindow?: number;
40
+ }): number;
package/dist/window.js ADDED
@@ -0,0 +1,47 @@
1
+ /** Ordered [pattern, conservative window tokens]. First match wins. */
2
+ const REGISTRY = [
3
+ [/claude/i, 200_000],
4
+ [/gpt-4\.1/i, 1_000_000],
5
+ [/gpt-5/i, 400_000],
6
+ [/gpt-4o/i, 128_000],
7
+ [/\bo[34](-mini)?\b/i, 200_000],
8
+ [/gemini/i, 1_000_000],
9
+ [/glm-\d/i, 131_072],
10
+ [/deepseek/i, 128_000],
11
+ [/kimi/i, 131_072],
12
+ [/qwen/i, 131_072],
13
+ [/llama/i, 128_000],
14
+ [/doubao/i, 128_000],
15
+ [/hunyuan|ernie|minimax|step-/i, 128_000],
16
+ ];
17
+ export function contextWindowFor(p) {
18
+ if (typeof p.contextWindow === 'number' && p.contextWindow > 0) {
19
+ return { windowTokens: p.contextWindow, source: 'config' };
20
+ }
21
+ for (const [re, tokens] of REGISTRY) {
22
+ if (re.test(p.model))
23
+ return { windowTokens: tokens, source: 'registry' };
24
+ }
25
+ return { windowTokens: null, source: 'unknown' };
26
+ }
27
+ /** Transcript char budget for a window: ~2.5 chars/token mixed-language
28
+ * (ASCII ~4, CJK ~1.2), and only HALF the window may be history - the rest
29
+ * belongs to system prompt, injected memory/skills, tool schemas and the
30
+ * reply. 128K tokens -> 160K chars, which is exactly the legacy default -
31
+ * known-window models scale from there, unknown models keep today's
32
+ * behaviour unchanged. */
33
+ export const CHARS_PER_TOKEN = 2.5;
34
+ export const HISTORY_WINDOW_FRACTION = 0.5;
35
+ export const MIN_CONTEXT_CHARS = 40_000;
36
+ export const MAX_CONTEXT_CHARS = 1_000_000;
37
+ export const LEGACY_DEFAULT_CONTEXT_CHARS = 160_000;
38
+ export function contextBudgetChars(windowTokens) {
39
+ if (windowTokens === null)
40
+ return LEGACY_DEFAULT_CONTEXT_CHARS;
41
+ const raw = windowTokens * CHARS_PER_TOKEN * HISTORY_WINDOW_FRACTION;
42
+ return Math.round(Math.min(MAX_CONTEXT_CHARS, Math.max(MIN_CONTEXT_CHARS, raw)));
43
+ }
44
+ /** One-call helper: provider -> adaptive transcript char budget. */
45
+ export function adaptiveContextChars(p) {
46
+ return contextBudgetChars(contextWindowFor(p).windowTokens);
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hmharness/kernel",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
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",