mu-harness 0.26.0 → 0.28.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.
Files changed (53) hide show
  1. package/esm/channels/channel.js +3 -0
  2. package/esm/channels/types.d.ts +3 -1
  3. package/esm/channels/ws/server.js +1 -1
  4. package/esm/commands/defaults.d.ts +9 -0
  5. package/esm/commands/defaults.js +127 -0
  6. package/esm/commands/index.d.ts +1 -1
  7. package/esm/commands/index.js +1 -1
  8. package/esm/commands/types.d.ts +3 -0
  9. package/esm/harness/compaction.d.ts +14 -0
  10. package/esm/harness/compaction.js +21 -0
  11. package/esm/harness/create.js +80 -3
  12. package/esm/harness/index.d.ts +3 -0
  13. package/esm/harness/index.js +3 -0
  14. package/esm/harness/instructions.d.ts +17 -0
  15. package/esm/harness/instructions.js +78 -0
  16. package/esm/harness/memory.d.ts +18 -0
  17. package/esm/harness/memory.js +59 -0
  18. package/esm/harness/types.d.ts +3 -0
  19. package/esm/hooks/merge-hooks.js +6 -0
  20. package/esm/hooks/types.d.ts +12 -0
  21. package/esm/session/agent-session.js +87 -12
  22. package/esm/session/manager.js +4 -0
  23. package/esm/session/persist.js +4 -0
  24. package/esm/session/types.d.ts +19 -0
  25. package/esm/tui/chat/ChatApp.d.ts +4 -1
  26. package/esm/tui/chat/ChatApp.js +22 -7
  27. package/package.json +3 -3
  28. package/script/channels/channel.js +3 -0
  29. package/script/channels/types.d.ts +3 -1
  30. package/script/channels/ws/server.js +1 -1
  31. package/script/commands/defaults.d.ts +9 -0
  32. package/script/commands/defaults.js +130 -1
  33. package/script/commands/index.d.ts +1 -1
  34. package/script/commands/index.js +3 -1
  35. package/script/commands/types.d.ts +3 -0
  36. package/script/harness/compaction.d.ts +14 -0
  37. package/script/harness/compaction.js +24 -0
  38. package/script/harness/create.js +79 -2
  39. package/script/harness/index.d.ts +3 -0
  40. package/script/harness/index.js +8 -1
  41. package/script/harness/instructions.d.ts +17 -0
  42. package/script/harness/instructions.js +82 -0
  43. package/script/harness/memory.d.ts +18 -0
  44. package/script/harness/memory.js +63 -0
  45. package/script/harness/types.d.ts +3 -0
  46. package/script/hooks/merge-hooks.js +6 -0
  47. package/script/hooks/types.d.ts +12 -0
  48. package/script/session/agent-session.js +87 -12
  49. package/script/session/manager.js +4 -0
  50. package/script/session/persist.js +4 -0
  51. package/script/session/types.d.ts +19 -0
  52. package/script/tui/chat/ChatApp.d.ts +4 -1
  53. package/script/tui/chat/ChatApp.js +22 -7
@@ -22,6 +22,77 @@ export const createAgentSession = (config) => {
22
22
  let started = false;
23
23
  let running = false;
24
24
  let controller;
25
+ // Assemble the request from the CURRENT in-memory messages — the real system (base +
26
+ // prepareRequest hook injections like the env block + tool prompt blocks), the post-hook
27
+ // tool set, and the live message list. Reflects "what the next turn would send".
28
+ const assembleRequest = async () => {
29
+ const hasSystem = messages[0]?.role === 'system';
30
+ const system = hasSystem ? textOf(messages[0]) : '';
31
+ const prepared = await hooks.prepareRequest?.({ system, tools: decorated });
32
+ const callTools = prepared?.tools ?? decorated;
33
+ const baseSystem = prepared?.system ?? system;
34
+ const toolBlock = callTools.map((tool) => tool.prompt?.trim()).filter(Boolean).join('\n');
35
+ const effectiveSystem = [baseSystem, toolBlock].filter(Boolean).join('\n\n');
36
+ const body = hasSystem ? messages.slice(1) : messages;
37
+ const withSystem = effectiveSystem ? [systemMessage(effectiveSystem), ...body] : body;
38
+ const callMessages = prepared?.messages?.length ? [...withSystem, ...prepared.messages] : withSystem;
39
+ return { system: effectiveSystem, tools: callTools, messages: callMessages };
40
+ };
41
+ const msgText = (m) => m.content
42
+ .map((p) => p.type === 'text'
43
+ ? p.text
44
+ : p.type === 'tool_call'
45
+ ? `[call ${p.name} ${JSON.stringify(p.input)}]`
46
+ : p.type === 'tool_result'
47
+ ? p.content.map((c) => (c.type === 'text' ? c.text : '')).join('')
48
+ : '')
49
+ .join(' ')
50
+ .trim();
51
+ /** Ask the model to summarize a slice of the conversation (for compaction). */
52
+ const summarize = async (msgs) => {
53
+ const transcript = msgs.map((m) => `${m.role}: ${msgText(m)}`).filter(Boolean).join('\n');
54
+ if (!transcript)
55
+ return '';
56
+ let out = '';
57
+ try {
58
+ for await (const ev of provider.stream({
59
+ model: config.model,
60
+ tools: [],
61
+ messages: [
62
+ {
63
+ role: 'system',
64
+ content: [{
65
+ type: 'text',
66
+ text: 'Summarize the conversation below for continuity. Preserve decisions, facts, file paths, code changes, and open tasks. Be concise. Output only the summary.',
67
+ }],
68
+ },
69
+ { role: 'user', content: [{ type: 'text', text: transcript }] },
70
+ ],
71
+ })) {
72
+ if (ev.type === 'text')
73
+ out += ev.text;
74
+ }
75
+ }
76
+ catch {
77
+ return '';
78
+ }
79
+ return out.trim();
80
+ };
81
+ /** Replace older messages with a summary, keeping the system message + the last N. */
82
+ const compact = async (opts) => {
83
+ const keep = Math.max(1, opts?.keepLastTurns ?? 6);
84
+ const sysCount = messages[0]?.role === 'system' ? 1 : 0;
85
+ if (messages.length <= sysCount + keep + 1)
86
+ return;
87
+ const middle = messages.slice(sysCount, messages.length - keep);
88
+ if (middle.length === 0)
89
+ return;
90
+ const summary = await summarize(middle);
91
+ if (!summary)
92
+ return;
93
+ const summaryMsg = { role: 'user', content: [{ type: 'text', text: `<summary>\n${summary}\n</summary>` }] };
94
+ messages.splice(sysCount, middle.length, summaryMsg);
95
+ };
25
96
  const send = async (input) => {
26
97
  if (running)
27
98
  throw new Error('AgentSession: busy (a turn is already running)');
@@ -37,21 +108,12 @@ export const createAgentSession = (config) => {
37
108
  const message = toMessage(input);
38
109
  messages.push(message);
39
110
  emitter.emit({ type: 'turn_start', input: message });
40
- const hasSystem = messages[0]?.role === 'system';
41
- const system = hasSystem ? textOf(messages[0]) : '';
42
- const prepared = await hooks.prepareRequest?.({ system, tools: decorated });
43
- const callTools = prepared?.tools ?? decorated;
44
- const baseSystem = prepared?.system ?? system;
45
- const toolBlock = callTools.map((tool) => tool.prompt?.trim()).filter(Boolean).join('\n');
46
- const effectiveSystem = [baseSystem, toolBlock].filter(Boolean).join('\n\n');
47
- const body = hasSystem ? messages.slice(1) : messages;
48
- const withSystem = effectiveSystem ? [systemMessage(effectiveSystem), ...body] : body;
49
- const callMessages = prepared?.messages?.length ? [...withSystem, ...prepared.messages] : withSystem;
111
+ const request = await assembleRequest();
50
112
  const events = run({
51
113
  provider,
52
114
  model: config.model,
53
- tools: callTools,
54
- messages: callMessages,
115
+ tools: [...request.tools],
116
+ messages: [...request.messages],
55
117
  signal: ac.signal,
56
118
  });
57
119
  for await (const event of events) {
@@ -69,6 +131,15 @@ export const createAgentSession = (config) => {
69
131
  controller = undefined;
70
132
  }
71
133
  emitter.emit(terminal);
134
+ // After a successful turn, let hooks compact/persist. Failures here never break the turn.
135
+ if (terminal.type === 'turn_end') {
136
+ await Promise.resolve(hooks.afterTurn?.({
137
+ messages,
138
+ countTokens: (t) => config.provider.countTokens?.(t, config.model) ?? Promise.resolve(undefined),
139
+ contextWindow: () => config.provider.contextWindow?.(config.model) ?? Promise.resolve(undefined),
140
+ compact,
141
+ })).catch(() => { });
142
+ }
72
143
  };
73
144
  return {
74
145
  id,
@@ -76,6 +147,10 @@ export const createAgentSession = (config) => {
76
147
  get messages() {
77
148
  return messages;
78
149
  },
150
+ assembleRequest,
151
+ countTokens: (text) => config.provider.countTokens?.(text, config.model) ?? Promise.resolve(undefined),
152
+ contextWindow: () => config.provider.contextWindow?.(config.model) ?? Promise.resolve(undefined),
153
+ compact,
79
154
  send,
80
155
  abort: () => controller?.abort(),
81
156
  subscribe: emitter.subscribe,
@@ -12,6 +12,10 @@ const onFirstMessage = (session, fire) => {
12
12
  get tools() {
13
13
  return session.tools;
14
14
  },
15
+ assembleRequest: session.assembleRequest?.bind(session),
16
+ countTokens: session.countTokens?.bind(session),
17
+ contextWindow: session.contextWindow?.bind(session),
18
+ compact: session.compact?.bind(session),
15
19
  send: async (input) => {
16
20
  if (pending) {
17
21
  pending = false;
@@ -10,6 +10,10 @@ export const persistTo = (store, session, persisted = 0) => {
10
10
  get tools() {
11
11
  return session.tools;
12
12
  },
13
+ assembleRequest: session.assembleRequest?.bind(session),
14
+ countTokens: session.countTokens?.bind(session),
15
+ contextWindow: session.contextWindow?.bind(session),
16
+ compact: session.compact?.bind(session),
13
17
  send: async (input) => {
14
18
  await session.send(input);
15
19
  const all = session.messages;
@@ -8,10 +8,29 @@ export type AgentSessionEvent = {
8
8
  type: 'error';
9
9
  error: unknown;
10
10
  };
11
+ /** The request the provider sees for a turn, assembled from the live session. */
12
+ export interface AssembledRequest {
13
+ /** Final system prompt: base system + prepareRequest hook injections + tool prompt blocks. */
14
+ system: string;
15
+ /** Final tool set (after prepareRequest hooks — may differ from session.tools, e.g. per-agent filtering). */
16
+ tools: readonly Tool[];
17
+ /** Final message list (system message + body + any hook-injected messages). */
18
+ messages: readonly Message[];
19
+ }
11
20
  export interface AgentSession {
12
21
  readonly id: string;
13
22
  readonly messages: readonly Message[];
14
23
  readonly tools: readonly Tool[];
24
+ /** Assemble the request from the CURRENT in-memory session — what the next turn would send. */
25
+ assembleRequest?(): Promise<AssembledRequest>;
26
+ /** Exact token count of `text` via the model's own tokenizer, when the provider supports it. */
27
+ countTokens?(text: string): Promise<number | undefined>;
28
+ /** The active model's context window in tokens, when reportable. */
29
+ contextWindow?(): Promise<number | undefined>;
30
+ /** Summarize older messages (keeping the system + last N) to free context. */
31
+ compact?(opts?: {
32
+ keepLastTurns?: number;
33
+ }): Promise<void>;
15
34
  send(input: string | ContentPart[]): Promise<void>;
16
35
  abort(): void;
17
36
  subscribe(listener: (event: AgentSessionEvent) => void): () => void;
@@ -49,7 +49,9 @@ export interface ChatHost {
49
49
  name: string;
50
50
  description: string;
51
51
  }[];
52
- runCommand?(input: string): Promise<{
52
+ runCommand?(input: string, ctx?: {
53
+ session?: AgentSession;
54
+ }): Promise<{
53
55
  ok: boolean;
54
56
  output?: unknown;
55
57
  error?: string;
@@ -75,6 +77,7 @@ export declare class ChatApp {
75
77
  private unsubscribeTheme;
76
78
  private unsubscribeSubAgents;
77
79
  private unsubscribeModelLoading;
80
+ private modelLoading;
78
81
  private readonly runUnsubs;
79
82
  private readonly activeRuns;
80
83
  private mentionAc;
@@ -103,6 +103,7 @@ export class ChatApp {
103
103
  unsubscribeTheme;
104
104
  unsubscribeSubAgents;
105
105
  unsubscribeModelLoading;
106
+ modelLoading = false;
106
107
  runUnsubs = new Set();
107
108
  activeRuns = new Set();
108
109
  mentionAc;
@@ -431,13 +432,18 @@ export class ChatApp {
431
432
  this.queue.length = 0;
432
433
  this.pendingShell.length = 0;
433
434
  this.running = false;
434
- this.status.busy = false;
435
435
  this.status.context = '';
436
- this.stopSpinner();
437
- this.setStatus('ready');
436
+ // A model switch sets this session swap in motion AND kicks off a model load;
437
+ // don't stomp the "loading…" spinner the load just put up.
438
+ if (!this.modelLoading) {
439
+ this.status.busy = false;
440
+ this.stopSpinner();
441
+ this.setStatus('ready');
442
+ }
438
443
  this.tui.requestRender(true);
439
444
  }
440
445
  onModelLoading(model, loading) {
446
+ this.modelLoading = loading;
441
447
  const name = model.split('/').pop() ?? model;
442
448
  if (loading) {
443
449
  this.status.busy = true;
@@ -819,7 +825,7 @@ export class ChatApp {
819
825
  name: c.name,
820
826
  description: c.description,
821
827
  run: async (args) => {
822
- const res = await this.host.runCommand(`/${c.name}${args ? ` ${args}` : ''}`);
828
+ const res = await this.host.runCommand(`/${c.name}${args ? ` ${args}` : ''}`, { session: this.session });
823
829
  if (res.ok) {
824
830
  if (res.output != null)
825
831
  this.transcript.note(String(res.output));
@@ -1085,7 +1091,14 @@ export class ChatApp {
1085
1091
  this.showError('Export path must stay inside the project directory.');
1086
1092
  return;
1087
1093
  }
1088
- const system = all.filter((message) => message.role === 'system').map(textOf).join('\n\n');
1094
+ // Assemble the EXACT request from the live session (real system = base + hook injections
1095
+ // + tool prompt blocks). Fall back to the stored messages if the session can't assemble.
1096
+ const last = await this.session?.assembleRequest?.();
1097
+ const system = last
1098
+ ? last.system
1099
+ : all.filter((message) => message.role === 'system').map(textOf).join('\n\n');
1100
+ const requestTools = last?.tools ?? this.session?.tools ?? [];
1101
+ const requestMessages = (last?.messages ?? all).filter((message) => message.role !== 'system');
1089
1102
  const payload = {
1090
1103
  exportedAt: new Date().toISOString(),
1091
1104
  session: {
@@ -1095,14 +1108,16 @@ export class ChatApp {
1095
1108
  model: this.host.modelRef(),
1096
1109
  },
1097
1110
  request: {
1111
+ // true = the exact payload last sent; false = reconstructed (no turn has run yet).
1112
+ assembled: last != null,
1098
1113
  system,
1099
- tools: (this.session?.tools ?? []).map((tool) => ({
1114
+ tools: requestTools.map((tool) => ({
1100
1115
  name: tool.name,
1101
1116
  description: tool.description,
1102
1117
  parameters: tool.parameters,
1103
1118
  ...(tool.prompt ? { prompt: tool.prompt } : {}),
1104
1119
  })),
1105
- messages: all.filter((message) => message.role !== 'system'),
1120
+ messages: requestMessages,
1106
1121
  },
1107
1122
  };
1108
1123
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mu-harness",
3
- "version": "0.26.0",
3
+ "version": "0.28.0",
4
4
  "description": "Agent harness: createHarness wires mu-core into a host — XDG paths, model registry, plugins, disk-loaded agents & skills, sub-agents, sessions (JSONL + SQLite catalog), slash commands, permission/approval hooks, an optional scheduler, and a composable TUI chat app",
5
5
  "license": "MIT",
6
6
  "main": "./script/index.js",
@@ -23,8 +23,8 @@
23
23
  "@swc/wasm-typescript": "^1.15.0",
24
24
  "cli-highlight": "^2.1.11",
25
25
  "croner": "^9.0.0",
26
- "mu-core": "^0.26.0",
27
- "mu-tui": "^0.26.0",
26
+ "mu-core": "^0.28.0",
27
+ "mu-tui": "^0.28.0",
28
28
  "ws": "^8.18.0"
29
29
  },
30
30
  "_generatedBy": "dnt@dev",
@@ -25,6 +25,9 @@ const createChannel = (config) => {
25
25
  get messages() {
26
26
  return session?.messages ?? [];
27
27
  },
28
+ get session() {
29
+ return session;
30
+ },
28
31
  send: async (input) => (await ensure()).send(input),
29
32
  abort: () => session?.abort(),
30
33
  subscribe: emitter.subscribe,
@@ -1,5 +1,5 @@
1
1
  import type { ContentPart, Message } from 'mu-core';
2
- import type { AgentSessionEvent } from '../session/index.js';
2
+ import type { AgentSession, AgentSessionEvent } from '../session/index.js';
3
3
  export type ChannelEvent = AgentSessionEvent | {
4
4
  type: 'channel_open';
5
5
  title: string;
@@ -14,6 +14,8 @@ export interface Channel {
14
14
  readonly title: string;
15
15
  readonly started: boolean;
16
16
  readonly messages: readonly Message[];
17
+ /** The live AgentSession, once started — exposes `lastRequest` for inspection commands. */
18
+ readonly session?: AgentSession;
17
19
  send(input: string | ContentPart[]): Promise<void>;
18
20
  abort(): void;
19
21
  subscribe(listener: (event: AgentSessionEvent) => void): () => void;
@@ -114,7 +114,7 @@ function webSocketAdapter(opts) {
114
114
  }
115
115
  case 'command': {
116
116
  const sessionId = msg.sessionId ?? client.sessionId;
117
- const result = await commands.run(msg.text, { sessionId });
117
+ const result = await commands.run(msg.text, { sessionId, session: manager.get(sessionId)?.session });
118
118
  if (result.ok) {
119
119
  if (result.output != null) {
120
120
  push({
@@ -8,4 +8,13 @@ export declare const createSessionsCommand: (sessions: SessionManager, options?:
8
8
  cwd?: string;
9
9
  }) => Command;
10
10
  export declare const createQuitCommand: (onQuit: () => void | Promise<void>) => Command;
11
+ /**
12
+ * Universal `/context` — the live session's context broken down into all categories
13
+ * (system / context / instructions / memory / tools / you / agent / tool-output), each
14
+ * counted with the model's own tokenizer, plus the context-window fill % and a colour
15
+ * heatmap. Works on every channel.
16
+ */
17
+ export declare const createContextCommand: () => Command;
18
+ /** Universal `/compact` — manually summarize older messages to free context now. */
19
+ export declare const createCompactCommand: () => Command;
11
20
  export declare const createHelpCommand: (list: () => Command[]) => Command;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.createHelpCommand = exports.createQuitCommand = exports.createSessionsCommand = exports.createSkillsCommand = exports.createAgentsCommand = void 0;
3
+ exports.createHelpCommand = exports.createCompactCommand = exports.createContextCommand = exports.createQuitCommand = exports.createSessionsCommand = exports.createSkillsCommand = exports.createAgentsCommand = void 0;
4
4
  const createAgentsCommand = (agents) => ({
5
5
  name: 'agents',
6
6
  description: 'List available agents',
@@ -47,6 +47,135 @@ const createQuitCommand = (onQuit) => ({
47
47
  },
48
48
  });
49
49
  exports.createQuitCommand = createQuitCommand;
50
+ const estTokens = (chars) => Math.max(1, Math.round(chars / 4));
51
+ const GRID_COLS = 24;
52
+ const GRID_ROWS = 8;
53
+ const GRID_CELLS = GRID_COLS * GRID_ROWS;
54
+ const BLOCK = '█';
55
+ const FREE = '·';
56
+ // ANSI SGR colours — mu's TUI text utils are ANSI-aware so these render in the terminal;
57
+ // the companion strips them (plain text), keeping the labelled breakdown readable.
58
+ const RESET = '\x1b[0m';
59
+ const DIM = '\x1b[2m';
60
+ const paint = (s, color) => `${color}${s}${RESET}`;
61
+ const fillColor = (pct) => (pct >= 80 ? '\x1b[31m' : pct >= 50 ? '\x1b[33m' : '\x1b[32m');
62
+ /** Extract a `<tag>…</tag>` block (with tags), or '' when absent. */
63
+ function tagBlock(s, tag) {
64
+ const i = s.indexOf(`<${tag}>`);
65
+ const j = s.indexOf(`</${tag}>`);
66
+ return i !== -1 && j > i ? s.slice(i, j + `</${tag}>`.length) : '';
67
+ }
68
+ /** Split the assembled system into its segments: agent prompt / env / instructions / memory / tool-prompts. */
69
+ function splitSystem(system) {
70
+ const env = tagBlock(system, 'env');
71
+ const instructions = tagBlock(system, 'instructions');
72
+ const memory = tagBlock(system, 'memory');
73
+ const opens = ['<env>', '<instructions>', '<memory>'].map((t) => system.indexOf(t)).filter((i) => i !== -1);
74
+ const closes = [
75
+ [env, '</env>'],
76
+ [instructions, '</instructions>'],
77
+ [memory, '</memory>'],
78
+ ]
79
+ .filter(([b]) => b)
80
+ .map(([, c]) => system.indexOf(c) + c.length);
81
+ const firstOpen = opens.length ? Math.min(...opens) : -1;
82
+ const lastClose = closes.length ? Math.max(...closes) : -1;
83
+ return {
84
+ agent: (firstOpen === -1 ? system : system.slice(0, firstOpen)).trim(),
85
+ env,
86
+ instructions,
87
+ memory,
88
+ toolPrompts: lastClose === -1 ? '' : system.slice(lastClose).trim(),
89
+ };
90
+ }
91
+ /**
92
+ * Universal `/context` — the live session's context broken down into all categories
93
+ * (system / context / instructions / memory / tools / you / agent / tool-output), each
94
+ * counted with the model's own tokenizer, plus the context-window fill % and a colour
95
+ * heatmap. Works on every channel.
96
+ */
97
+ const createContextCommand = () => ({
98
+ name: 'context',
99
+ description: 'Show the live context: per-category tokens, window fill %, and a heatmap',
100
+ run: async (_args, ctx) => {
101
+ const last = await ctx.session?.assembleRequest?.();
102
+ if (!last)
103
+ return { ok: true, output: 'No session in memory yet — start a conversation first.' };
104
+ const sys = splitSystem(last.system);
105
+ const toolSchemas = last.tools
106
+ .map((t) => JSON.stringify({ name: t.name, description: t.description, parameters: t.parameters }))
107
+ .join('\n');
108
+ const body = last.messages.filter((m) => m.role !== 'system');
109
+ const byRole = (role) => body.filter((m) => m.role === role).map((m) => JSON.stringify(m.content)).join('\n');
110
+ const count = ctx.session?.countTokens;
111
+ const measure = async (text) => {
112
+ if (!text)
113
+ return { n: 0, exact: true };
114
+ if (count) {
115
+ const n = await count(text);
116
+ if (n !== undefined)
117
+ return { n, exact: true };
118
+ }
119
+ return { n: estTokens(text.length), exact: false };
120
+ };
121
+ // label, text, ANSI colour — one per category, in render order.
122
+ const SPEC = [
123
+ ['system', sys.agent, '\x1b[36m'], // cyan — the agent prompt
124
+ ['context', sys.env, '\x1b[33m'], // yellow — the <env> block
125
+ ['instructions', sys.instructions, '\x1b[34m'], // blue — AGENTS.md / CLAUDE.md
126
+ ['memory', sys.memory, '\x1b[35m'], // magenta — MEMORY.md
127
+ ['tools', `${toolSchemas}\n${sys.toolPrompts}`, '\x1b[31m'], // red — schemas + tool prompts
128
+ ['you', byRole('user'), '\x1b[32m'], // green — your messages
129
+ ['agent', byRole('assistant'), '\x1b[94m'], // bright blue — assistant replies
130
+ ['tool-out', byRole('tool'), '\x1b[90m'], // grey — tool results
131
+ ];
132
+ const measured = await Promise.all(SPEC.map(([, text]) => measure(text)));
133
+ const cats = SPEC.map(([label, , color], i) => ({ label, n: measured[i].n, color })).filter((c) => c.n > 0);
134
+ const exact = measured.every((m) => m.exact);
135
+ const total = cats.reduce((s, c) => s + c.n, 0);
136
+ const window = (await ctx.session?.contextWindow?.()) ?? 0;
137
+ const mark = (n) => (exact ? `${n}` : `~${n}`);
138
+ const lines = [`context — tokens (${exact ? 'exact, model tokenizer' : 'estimated ≈ chars/4'}):`];
139
+ for (const c of cats)
140
+ lines.push(` ${paint(BLOCK, c.color)} ${c.label.padEnd(13)} ${mark(c.n)}`);
141
+ const pctNum = window ? Math.round((total / window) * 100) : 0;
142
+ const pct = window ? ` / ${window} ${paint(`(${pctNum}%)`, fillColor(pctNum))}` : '';
143
+ lines.push(` ${' '.repeat(15)}── ${mark(total)}${pct}`);
144
+ if (window > 0) {
145
+ const cellTokens = Math.max(1, window / GRID_CELLS);
146
+ const cells = [];
147
+ for (const c of cats) {
148
+ for (let i = 0; i < Math.round(c.n / cellTokens) && cells.length < GRID_CELLS; i++)
149
+ cells.push(paint(BLOCK, c.color));
150
+ }
151
+ while (cells.length < GRID_CELLS)
152
+ cells.push(paint(FREE, DIM));
153
+ cells.length = GRID_CELLS;
154
+ lines.push('');
155
+ for (let r = 0; r < GRID_ROWS; r++)
156
+ lines.push(` ${cells.slice(r * GRID_COLS, (r + 1) * GRID_COLS).join('')}`);
157
+ }
158
+ return { ok: true, output: lines.join('\n') };
159
+ },
160
+ });
161
+ exports.createContextCommand = createContextCommand;
162
+ /** Universal `/compact` — manually summarize older messages to free context now. */
163
+ const createCompactCommand = () => ({
164
+ name: 'compact',
165
+ description: 'Summarize older messages to free up context now',
166
+ run: async (_args, ctx) => {
167
+ if (!ctx.session?.compact)
168
+ return { ok: true, output: 'Compaction is not available on this session.' };
169
+ const before = ctx.session.messages?.length ?? 0;
170
+ await ctx.session.compact();
171
+ const after = ctx.session.messages?.length ?? 0;
172
+ return {
173
+ ok: true,
174
+ output: after < before ? `Compacted: ${before} → ${after} messages.` : 'Nothing to compact yet.',
175
+ };
176
+ },
177
+ });
178
+ exports.createCompactCommand = createCompactCommand;
50
179
  const createHelpCommand = (list) => ({
51
180
  name: 'help',
52
181
  description: 'Show available commands',
@@ -1,4 +1,4 @@
1
- export { createAgentsCommand, createHelpCommand, createQuitCommand, createSessionsCommand, createSkillsCommand, } from './defaults.js';
1
+ export { createAgentsCommand, createCompactCommand, createContextCommand, createHelpCommand, createQuitCommand, createSessionsCommand, createSkillsCommand, } from './defaults.js';
2
2
  export { createCommandRegistry } from './registry.js';
3
3
  export { createSkillCommand, type SkillCommandDeps } from './skill.js';
4
4
  export type { Command, CommandContext, CommandRegistry, CommandResult } from './types.js';
@@ -1,8 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.createSkillCommand = exports.createCommandRegistry = exports.createSkillsCommand = exports.createSessionsCommand = exports.createQuitCommand = exports.createHelpCommand = exports.createAgentsCommand = void 0;
3
+ exports.createSkillCommand = exports.createCommandRegistry = exports.createSkillsCommand = exports.createSessionsCommand = exports.createQuitCommand = exports.createHelpCommand = exports.createContextCommand = exports.createCompactCommand = exports.createAgentsCommand = void 0;
4
4
  var defaults_js_1 = require("./defaults.js");
5
5
  Object.defineProperty(exports, "createAgentsCommand", { enumerable: true, get: function () { return defaults_js_1.createAgentsCommand; } });
6
+ Object.defineProperty(exports, "createCompactCommand", { enumerable: true, get: function () { return defaults_js_1.createCompactCommand; } });
7
+ Object.defineProperty(exports, "createContextCommand", { enumerable: true, get: function () { return defaults_js_1.createContextCommand; } });
6
8
  Object.defineProperty(exports, "createHelpCommand", { enumerable: true, get: function () { return defaults_js_1.createHelpCommand; } });
7
9
  Object.defineProperty(exports, "createQuitCommand", { enumerable: true, get: function () { return defaults_js_1.createQuitCommand; } });
8
10
  Object.defineProperty(exports, "createSessionsCommand", { enumerable: true, get: function () { return defaults_js_1.createSessionsCommand; } });
@@ -1,3 +1,4 @@
1
+ import type { AgentSession } from '../session/index.js';
1
2
  export interface CommandResult {
2
3
  ok: boolean;
3
4
  output?: unknown;
@@ -5,6 +6,8 @@ export interface CommandResult {
5
6
  }
6
7
  export interface CommandContext {
7
8
  sessionId?: string;
9
+ /** The live session for this invocation — lets a command inspect what the model actually saw. */
10
+ session?: AgentSession;
8
11
  }
9
12
  export interface Command {
10
13
  name: string;
@@ -0,0 +1,14 @@
1
+ import type { AgentSessionHooks } from '../hooks/index.js';
2
+ export interface CompactionOptions {
3
+ /** Compact once estimated usage exceeds this fraction of the context window. Default 0.8. */
4
+ thresholdPct?: number;
5
+ /** Number of most-recent messages kept verbatim through a compaction. Default 6. */
6
+ keepLastTurns?: number;
7
+ }
8
+ /**
9
+ * An `afterTurn` hook that auto-compacts the conversation as it approaches the context
10
+ * window: when the estimated token usage crosses `thresholdPct`, older messages are
11
+ * summarized (keeping the system message + the last `keepLastTurns`). The gap between the
12
+ * threshold and the window is the reserved compaction buffer.
13
+ */
14
+ export declare function createCompactionHook(opts?: CompactionOptions): AgentSessionHooks;
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createCompactionHook = createCompactionHook;
4
+ /**
5
+ * An `afterTurn` hook that auto-compacts the conversation as it approaches the context
6
+ * window: when the estimated token usage crosses `thresholdPct`, older messages are
7
+ * summarized (keeping the system message + the last `keepLastTurns`). The gap between the
8
+ * threshold and the window is the reserved compaction buffer.
9
+ */
10
+ function createCompactionHook(opts = {}) {
11
+ const thresholdPct = opts.thresholdPct ?? 0.8;
12
+ const keepLastTurns = opts.keepLastTurns ?? 6;
13
+ return {
14
+ afterTurn: async ({ messages, contextWindow, compact }) => {
15
+ const window = await contextWindow();
16
+ if (!window)
17
+ return;
18
+ // Cheap chars/4 estimate — avoids a tokenizer round-trip on every turn.
19
+ const estimate = messages.reduce((n, m) => n + JSON.stringify(m.content).length, 0) / 4;
20
+ if (estimate > window * thresholdPct)
21
+ await compact({ keepLastTurns });
22
+ },
23
+ };
24
+ }