mu-harness 0.27.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 +3 -1
  26. package/esm/tui/chat/ChatApp.js +13 -4
  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 +3 -1
  53. package/script/tui/chat/ChatApp.js +13 -4
@@ -22,6 +22,9 @@ export const createChannel = (config) => {
22
22
  get messages() {
23
23
  return session?.messages ?? [];
24
24
  },
25
+ get session() {
26
+ return session;
27
+ },
25
28
  send: async (input) => (await ensure()).send(input),
26
29
  abort: () => session?.abort(),
27
30
  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;
@@ -111,7 +111,7 @@ export function webSocketAdapter(opts) {
111
111
  }
112
112
  case 'command': {
113
113
  const sessionId = msg.sessionId ?? client.sessionId;
114
- const result = await commands.run(msg.text, { sessionId });
114
+ const result = await commands.run(msg.text, { sessionId, session: manager.get(sessionId)?.session });
115
115
  if (result.ok) {
116
116
  if (result.output != null) {
117
117
  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;
@@ -40,6 +40,133 @@ export const createQuitCommand = (onQuit) => ({
40
40
  return { ok: true };
41
41
  },
42
42
  });
43
+ const estTokens = (chars) => Math.max(1, Math.round(chars / 4));
44
+ const GRID_COLS = 24;
45
+ const GRID_ROWS = 8;
46
+ const GRID_CELLS = GRID_COLS * GRID_ROWS;
47
+ const BLOCK = '█';
48
+ const FREE = '·';
49
+ // ANSI SGR colours — mu's TUI text utils are ANSI-aware so these render in the terminal;
50
+ // the companion strips them (plain text), keeping the labelled breakdown readable.
51
+ const RESET = '\x1b[0m';
52
+ const DIM = '\x1b[2m';
53
+ const paint = (s, color) => `${color}${s}${RESET}`;
54
+ const fillColor = (pct) => (pct >= 80 ? '\x1b[31m' : pct >= 50 ? '\x1b[33m' : '\x1b[32m');
55
+ /** Extract a `<tag>…</tag>` block (with tags), or '' when absent. */
56
+ function tagBlock(s, tag) {
57
+ const i = s.indexOf(`<${tag}>`);
58
+ const j = s.indexOf(`</${tag}>`);
59
+ return i !== -1 && j > i ? s.slice(i, j + `</${tag}>`.length) : '';
60
+ }
61
+ /** Split the assembled system into its segments: agent prompt / env / instructions / memory / tool-prompts. */
62
+ function splitSystem(system) {
63
+ const env = tagBlock(system, 'env');
64
+ const instructions = tagBlock(system, 'instructions');
65
+ const memory = tagBlock(system, 'memory');
66
+ const opens = ['<env>', '<instructions>', '<memory>'].map((t) => system.indexOf(t)).filter((i) => i !== -1);
67
+ const closes = [
68
+ [env, '</env>'],
69
+ [instructions, '</instructions>'],
70
+ [memory, '</memory>'],
71
+ ]
72
+ .filter(([b]) => b)
73
+ .map(([, c]) => system.indexOf(c) + c.length);
74
+ const firstOpen = opens.length ? Math.min(...opens) : -1;
75
+ const lastClose = closes.length ? Math.max(...closes) : -1;
76
+ return {
77
+ agent: (firstOpen === -1 ? system : system.slice(0, firstOpen)).trim(),
78
+ env,
79
+ instructions,
80
+ memory,
81
+ toolPrompts: lastClose === -1 ? '' : system.slice(lastClose).trim(),
82
+ };
83
+ }
84
+ /**
85
+ * Universal `/context` — the live session's context broken down into all categories
86
+ * (system / context / instructions / memory / tools / you / agent / tool-output), each
87
+ * counted with the model's own tokenizer, plus the context-window fill % and a colour
88
+ * heatmap. Works on every channel.
89
+ */
90
+ export const createContextCommand = () => ({
91
+ name: 'context',
92
+ description: 'Show the live context: per-category tokens, window fill %, and a heatmap',
93
+ run: async (_args, ctx) => {
94
+ const last = await ctx.session?.assembleRequest?.();
95
+ if (!last)
96
+ return { ok: true, output: 'No session in memory yet — start a conversation first.' };
97
+ const sys = splitSystem(last.system);
98
+ const toolSchemas = last.tools
99
+ .map((t) => JSON.stringify({ name: t.name, description: t.description, parameters: t.parameters }))
100
+ .join('\n');
101
+ const body = last.messages.filter((m) => m.role !== 'system');
102
+ const byRole = (role) => body.filter((m) => m.role === role).map((m) => JSON.stringify(m.content)).join('\n');
103
+ const count = ctx.session?.countTokens;
104
+ const measure = async (text) => {
105
+ if (!text)
106
+ return { n: 0, exact: true };
107
+ if (count) {
108
+ const n = await count(text);
109
+ if (n !== undefined)
110
+ return { n, exact: true };
111
+ }
112
+ return { n: estTokens(text.length), exact: false };
113
+ };
114
+ // label, text, ANSI colour — one per category, in render order.
115
+ const SPEC = [
116
+ ['system', sys.agent, '\x1b[36m'], // cyan — the agent prompt
117
+ ['context', sys.env, '\x1b[33m'], // yellow — the <env> block
118
+ ['instructions', sys.instructions, '\x1b[34m'], // blue — AGENTS.md / CLAUDE.md
119
+ ['memory', sys.memory, '\x1b[35m'], // magenta — MEMORY.md
120
+ ['tools', `${toolSchemas}\n${sys.toolPrompts}`, '\x1b[31m'], // red — schemas + tool prompts
121
+ ['you', byRole('user'), '\x1b[32m'], // green — your messages
122
+ ['agent', byRole('assistant'), '\x1b[94m'], // bright blue — assistant replies
123
+ ['tool-out', byRole('tool'), '\x1b[90m'], // grey — tool results
124
+ ];
125
+ const measured = await Promise.all(SPEC.map(([, text]) => measure(text)));
126
+ const cats = SPEC.map(([label, , color], i) => ({ label, n: measured[i].n, color })).filter((c) => c.n > 0);
127
+ const exact = measured.every((m) => m.exact);
128
+ const total = cats.reduce((s, c) => s + c.n, 0);
129
+ const window = (await ctx.session?.contextWindow?.()) ?? 0;
130
+ const mark = (n) => (exact ? `${n}` : `~${n}`);
131
+ const lines = [`context — tokens (${exact ? 'exact, model tokenizer' : 'estimated ≈ chars/4'}):`];
132
+ for (const c of cats)
133
+ lines.push(` ${paint(BLOCK, c.color)} ${c.label.padEnd(13)} ${mark(c.n)}`);
134
+ const pctNum = window ? Math.round((total / window) * 100) : 0;
135
+ const pct = window ? ` / ${window} ${paint(`(${pctNum}%)`, fillColor(pctNum))}` : '';
136
+ lines.push(` ${' '.repeat(15)}── ${mark(total)}${pct}`);
137
+ if (window > 0) {
138
+ const cellTokens = Math.max(1, window / GRID_CELLS);
139
+ const cells = [];
140
+ for (const c of cats) {
141
+ for (let i = 0; i < Math.round(c.n / cellTokens) && cells.length < GRID_CELLS; i++)
142
+ cells.push(paint(BLOCK, c.color));
143
+ }
144
+ while (cells.length < GRID_CELLS)
145
+ cells.push(paint(FREE, DIM));
146
+ cells.length = GRID_CELLS;
147
+ lines.push('');
148
+ for (let r = 0; r < GRID_ROWS; r++)
149
+ lines.push(` ${cells.slice(r * GRID_COLS, (r + 1) * GRID_COLS).join('')}`);
150
+ }
151
+ return { ok: true, output: lines.join('\n') };
152
+ },
153
+ });
154
+ /** Universal `/compact` — manually summarize older messages to free context now. */
155
+ export const createCompactCommand = () => ({
156
+ name: 'compact',
157
+ description: 'Summarize older messages to free up context now',
158
+ run: async (_args, ctx) => {
159
+ if (!ctx.session?.compact)
160
+ return { ok: true, output: 'Compaction is not available on this session.' };
161
+ const before = ctx.session.messages?.length ?? 0;
162
+ await ctx.session.compact();
163
+ const after = ctx.session.messages?.length ?? 0;
164
+ return {
165
+ ok: true,
166
+ output: after < before ? `Compacted: ${before} → ${after} messages.` : 'Nothing to compact yet.',
167
+ };
168
+ },
169
+ });
43
170
  export const createHelpCommand = (list) => ({
44
171
  name: 'help',
45
172
  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,3 +1,3 @@
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 } from './skill.js';
@@ -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,21 @@
1
+ /**
2
+ * An `afterTurn` hook that auto-compacts the conversation as it approaches the context
3
+ * window: when the estimated token usage crosses `thresholdPct`, older messages are
4
+ * summarized (keeping the system message + the last `keepLastTurns`). The gap between the
5
+ * threshold and the window is the reserved compaction buffer.
6
+ */
7
+ export function createCompactionHook(opts = {}) {
8
+ const thresholdPct = opts.thresholdPct ?? 0.8;
9
+ const keepLastTurns = opts.keepLastTurns ?? 6;
10
+ return {
11
+ afterTurn: async ({ messages, contextWindow, compact }) => {
12
+ const window = await contextWindow();
13
+ if (!window)
14
+ return;
15
+ // Cheap chars/4 estimate — avoids a tokenizer round-trip on every turn.
16
+ const estimate = messages.reduce((n, m) => n + JSON.stringify(m.content).length, 0) / 4;
17
+ if (estimate > window * thresholdPct)
18
+ await compact({ keepLastTurns });
19
+ },
20
+ };
21
+ }
@@ -2,7 +2,7 @@ import os from 'node:os';
2
2
  import { join } from 'node:path';
3
3
  import process from 'node:process';
4
4
  import { createAgentRegistry, grantArg, loadAgents, toolDecision, toolNames } from '../agents/index.js';
5
- import { createAgentsCommand, createCommandRegistry, createHelpCommand, createSessionsCommand, createSkillCommand, createSkillsCommand, } from '../commands/index.js';
5
+ import { createAgentsCommand, createCommandRegistry, createCompactCommand, createContextCommand, createHelpCommand, createSessionsCommand, createSkillCommand, createSkillsCommand, } from '../commands/index.js';
6
6
  import { createHarnessConfig } from '../config/index.js';
7
7
  import { mergeHooks } from '../hooks/index.js';
8
8
  import { allowList } from '../permissions/index.js';
@@ -12,6 +12,9 @@ import { createAgentSession, createSessionCatalog, createSessionManager, createS
12
12
  import { createRunSkillTool, createSkillRegistry, createSkillTool, loadSkills, runSkill } from '../skills/index.js';
13
13
  import { createSubAgentRegistry, createSubAgentTool, runSubAgent } from '../subAgents/index.js';
14
14
  import { environmentBlock } from './environment.js';
15
+ import { dirsForPath, loadInstructions } from './instructions.js';
16
+ import { createMemoryStore, createRememberTool } from './memory.js';
17
+ import { createCompactionHook } from './compaction.js';
15
18
  import { createModelRegistry } from './models.js';
16
19
  const TITLE_AGENT = {
17
20
  name: 'title',
@@ -19,6 +22,24 @@ const TITLE_AGENT = {
19
22
  prompt: 'Generate a concise title (3 to 6 words) for the conversation based on the user message. Reply with ONLY the title — no quotes, no trailing punctuation, no explanation.',
20
23
  tools: [],
21
24
  };
25
+ /** Tool-input field names that carry filesystem paths — used to scope nested AGENTS.md. */
26
+ const PATH_KEYS = new Set(['path', 'file', 'filename', 'file_path', 'filepath', 'dir', 'directory', 'cwd', 'paths', 'files']);
27
+ function pathsFromInput(input) {
28
+ if (!input || typeof input !== 'object')
29
+ return [];
30
+ const out = [];
31
+ for (const [key, value] of Object.entries(input)) {
32
+ if (!PATH_KEYS.has(key.toLowerCase()))
33
+ continue;
34
+ if (typeof value === 'string')
35
+ out.push(value);
36
+ else if (Array.isArray(value))
37
+ for (const el of value)
38
+ if (typeof el === 'string')
39
+ out.push(el);
40
+ }
41
+ return out;
42
+ }
22
43
  export const createHarness = async (options) => {
23
44
  const { hostName, xdg, providers, model, agents: hostAgents = [], defaultAgents = [], skills: hostSkills = [], agentDirs, title, titleModel, scheduler: enableScheduler = false, approvals, ...sessionDefaults } = options;
24
45
  const cwd = options.cwd ?? process.cwd();
@@ -53,6 +74,42 @@ export const createHarness = async (options) => {
53
74
  const envHook = {
54
75
  prepareRequest: ({ system }) => ({ system: system ? `${system}\n\n${envBlock}` : envBlock }),
55
76
  };
77
+ // Project/global instructions (AGENTS.md / CLAUDE.md). Scopes: GLOBAL (configDir) + LOCAL
78
+ // (cwd & ancestors) + on-demand NESTED (subdirs the agent touches). `accessedDirs` grows as
79
+ // tools reference paths; the hook re-loads each turn so nested AGENTS.md appear when relevant.
80
+ const accessedDirs = new Set();
81
+ const trackPathsHook = {
82
+ beforeToolCall: ({ input }) => {
83
+ for (const p of pathsFromInput(input))
84
+ for (const d of dirsForPath(cwd, p))
85
+ accessedDirs.add(d);
86
+ },
87
+ };
88
+ const instructionsHook = {
89
+ prepareRequest: async ({ system }) => {
90
+ const block = await loadInstructions(cwd, config.configDir, { accessed: accessedDirs });
91
+ if (!block)
92
+ return undefined;
93
+ const tagged = `<instructions>\n${block}\n</instructions>`;
94
+ return { system: system ? `${system}\n\n${tagged}` : tagged };
95
+ },
96
+ };
97
+ // Memory: GLOBAL (dataDir/MEMORY.md) + LOCAL (cwd/.mu/MEMORY.md), written via the `remember`
98
+ // tool. Re-loaded each turn so a memory saved this session shows up on the next.
99
+ const memory = createMemoryStore({ cwd, dataDir: config.dataDir });
100
+ const memoryHook = {
101
+ prepareRequest: async ({ system }) => {
102
+ const block = await memory.load();
103
+ if (!block)
104
+ return undefined;
105
+ const tagged = `<memory>\n${block}\n</memory>`;
106
+ return { system: system ? `${system}\n\n${tagged}` : tagged };
107
+ },
108
+ };
109
+ // Auto-compaction: summarize old messages as the context fills (enabled unless turned off).
110
+ const compactionHook = options.compaction === false
111
+ ? {}
112
+ : createCompactionHook(options.compaction || {});
56
113
  const pluginSkills = (sessionDefaults.plugins ?? []).flatMap((plugin) => plugin.skills ?? []);
57
114
  const mergedSkills = async () => [
58
115
  ...hostSkills,
@@ -74,6 +131,7 @@ export const createHarness = async (options) => {
74
131
  ...(sessionDefaults.tools ?? []),
75
132
  ...extra,
76
133
  createSkillTool(scopeSkills(agent)),
134
+ createRememberTool(memory),
77
135
  ...schedulerTools,
78
136
  ];
79
137
  const approvalHook = (getAgent) => approvals
@@ -99,7 +157,16 @@ export const createHarness = async (options) => {
99
157
  });
100
158
  const spawn = (agent) => persistTo(store, persona(agent, {
101
159
  tools: sessionTools(agent),
102
- hooks: mergeHooks([sessionDefaults.hooks, allowList(toolNames(agent)), approvalHook(() => agent), envHook]),
160
+ hooks: mergeHooks([
161
+ sessionDefaults.hooks,
162
+ allowList(toolNames(agent)),
163
+ approvalHook(() => agent),
164
+ envHook,
165
+ instructionsHook,
166
+ memoryHook,
167
+ trackPathsHook,
168
+ compactionHook,
169
+ ]),
103
170
  }));
104
171
  const scheduler = enableScheduler && tasks
105
172
  ? createScheduler({
@@ -147,7 +214,15 @@ export const createHarness = async (options) => {
147
214
  },
148
215
  revive: ({ id, model: ref, messages }) => createAgentSession({
149
216
  ...sessionDefaults,
150
- hooks: mergeHooks([sessionDefaults.hooks, approvalHook(() => approvals?.activeAgent()), envHook]),
217
+ hooks: mergeHooks([
218
+ sessionDefaults.hooks,
219
+ approvalHook(() => approvals?.activeAgent()),
220
+ envHook,
221
+ instructionsHook,
222
+ memoryHook,
223
+ trackPathsHook,
224
+ compactionHook,
225
+ ]),
151
226
  tools: sessionTools(undefined, [createSubAgentTool({ registry: agents, spawn, runs, parentId: id })]),
152
227
  ...models.resolve(ref),
153
228
  id,
@@ -158,6 +233,8 @@ export const createHarness = async (options) => {
158
233
  createAgentsCommand(agents),
159
234
  createSkillsCommand(skills),
160
235
  createSessionsCommand(sessions, { cwd }),
236
+ createContextCommand(),
237
+ createCompactCommand(),
161
238
  ...(tasks ? [createTasksCommand(tasks)] : []),
162
239
  ]);
163
240
  commands.register(createHelpCommand(() => commands.list()));
@@ -1,3 +1,6 @@
1
1
  export type { Harness, HarnessOptions } from './types.js';
2
2
  export { createHarness } from './create.js';
3
+ export { type CompactionOptions, createCompactionHook } from './compaction.js';
4
+ export { createMemoryStore, createRememberTool, type MemoryScope, type MemoryStore } from './memory.js';
5
+ export { loadInstructions } from './instructions.js';
3
6
  export { createModelRegistry, type ModelRegistry, type ModelRegistryOptions, type ResolvedModel } from './models.js';
@@ -1,2 +1,5 @@
1
1
  export { createHarness } from './create.js';
2
+ export { createCompactionHook } from './compaction.js';
3
+ export { createMemoryStore, createRememberTool } from './memory.js';
4
+ export { loadInstructions } from './instructions.js';
2
5
  export { createModelRegistry } from './models.js';
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The directory chain from a file's directory UP to (and including) `cwd`. Used to scope
3
+ * nested AGENTS.md: touching `cwd/a/b/c.ts` makes `cwd`, `cwd/a`, `cwd/a/b` relevant.
4
+ * Returns [] when the path is outside `cwd`.
5
+ */
6
+ export declare function dirsForPath(cwd: string, filePath: string): string[];
7
+ /**
8
+ * Load project-instruction files (AGENTS.md / CLAUDE.md, opencode-style) across harness
9
+ * scopes: GLOBAL (configDir, every project) and LOCAL (cwd + ancestors, project-specific),
10
+ * plus on-demand NESTED scopes — any subdirectory the agent has touched (`accessed`) whose
11
+ * AGENTS.md should apply while working there. Concatenated shallow → deep so the most
12
+ * specific instructions land last. Returns undefined when nothing is found.
13
+ */
14
+ export declare function loadInstructions(cwd: string, configDir: string, opts?: {
15
+ files?: readonly string[];
16
+ accessed?: Iterable<string>;
17
+ }): Promise<string | undefined>;
@@ -0,0 +1,78 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { dirname, isAbsolute, join, sep } from 'node:path';
3
+ /** Filenames recognised as project/global instruction files, in priority order per directory. */
4
+ const DEFAULT_FILES = ['AGENTS.md', 'CLAUDE.md'];
5
+ const isUnder = (root, p) => p === root || p.startsWith(root.endsWith(sep) ? root : root + sep);
6
+ /** Walk from `cwd` up to the filesystem root (capped), returning dirs root-first → cwd-last. */
7
+ function ancestorDirs(cwd, max = 12) {
8
+ const dirs = [];
9
+ let dir = cwd;
10
+ for (let i = 0; i < max; i++) {
11
+ dirs.push(dir);
12
+ const parent = dirname(dir);
13
+ if (parent === dir)
14
+ break;
15
+ dir = parent;
16
+ }
17
+ return dirs.reverse();
18
+ }
19
+ /**
20
+ * The directory chain from a file's directory UP to (and including) `cwd`. Used to scope
21
+ * nested AGENTS.md: touching `cwd/a/b/c.ts` makes `cwd`, `cwd/a`, `cwd/a/b` relevant.
22
+ * Returns [] when the path is outside `cwd`.
23
+ */
24
+ export function dirsForPath(cwd, filePath) {
25
+ if (!filePath)
26
+ return [];
27
+ const abs = isAbsolute(filePath) ? filePath : join(cwd, filePath);
28
+ if (!isUnder(cwd, abs))
29
+ return [];
30
+ const dirs = [];
31
+ let dir = dirname(abs);
32
+ while (isUnder(cwd, dir)) {
33
+ dirs.push(dir);
34
+ if (dir === cwd)
35
+ break;
36
+ const parent = dirname(dir);
37
+ if (parent === dir)
38
+ break;
39
+ dir = parent;
40
+ }
41
+ return dirs;
42
+ }
43
+ /** Read the first existing file (by `names`) in `dir`, returning its trimmed content. */
44
+ async function readFirst(dir, names) {
45
+ for (const name of names) {
46
+ const text = await readFile(join(dir, name), 'utf-8').catch(() => undefined);
47
+ if (text && text.trim())
48
+ return { path: join(dir, name), text: text.trim() };
49
+ }
50
+ return undefined;
51
+ }
52
+ /**
53
+ * Load project-instruction files (AGENTS.md / CLAUDE.md, opencode-style) across harness
54
+ * scopes: GLOBAL (configDir, every project) and LOCAL (cwd + ancestors, project-specific),
55
+ * plus on-demand NESTED scopes — any subdirectory the agent has touched (`accessed`) whose
56
+ * AGENTS.md should apply while working there. Concatenated shallow → deep so the most
57
+ * specific instructions land last. Returns undefined when nothing is found.
58
+ */
59
+ export async function loadInstructions(cwd, configDir, opts) {
60
+ const files = opts?.files ?? DEFAULT_FILES;
61
+ const parts = [];
62
+ const global = await readFirst(configDir, files);
63
+ if (global)
64
+ parts.push(`<!-- global: ${global.path} -->\n${global.text}`);
65
+ const dirSet = new Set(ancestorDirs(cwd));
66
+ for (const dir of opts?.accessed ?? [])
67
+ if (isUnder(cwd, dir))
68
+ dirSet.add(dir);
69
+ const dirs = [...dirSet]
70
+ .filter((d) => d !== configDir)
71
+ .sort((a, b) => a.split(sep).length - b.split(sep).length);
72
+ for (const dir of dirs) {
73
+ const local = await readFirst(dir, files);
74
+ if (local)
75
+ parts.push(`<!-- ${local.path} -->\n${local.text}`);
76
+ }
77
+ return parts.length > 0 ? parts.join('\n\n') : undefined;
78
+ }
@@ -0,0 +1,18 @@
1
+ import { type Tool } from 'mu-core';
2
+ export type MemoryScope = 'local' | 'global';
3
+ export interface MemoryStore {
4
+ /** Project-scoped memory file (cwd/.mu/MEMORY.md). */
5
+ readonly localPath: string;
6
+ /** Global memory file (dataDir/MEMORY.md), shared across all projects. */
7
+ readonly globalPath: string;
8
+ /** Concatenated memory (global then project), capped per scope, or undefined when empty. */
9
+ load(): Promise<string | undefined>;
10
+ /** Append a durable fact to the chosen scope's memory file. */
11
+ remember(fact: string, scope: MemoryScope): Promise<void>;
12
+ }
13
+ export declare function createMemoryStore(opts: {
14
+ cwd: string;
15
+ dataDir: string;
16
+ }): MemoryStore;
17
+ /** A `remember` tool the agent calls to persist durable facts to local or global memory. */
18
+ export declare function createRememberTool(store: MemoryStore): Tool;
@@ -0,0 +1,59 @@
1
+ import { appendFile, mkdir, readFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { text } from 'mu-core';
4
+ /** Cap per scope so a runaway memory file can't dominate the context (≈ Claude Code's 25KB). */
5
+ const MAX_BYTES = 24 * 1024;
6
+ export function createMemoryStore(opts) {
7
+ const localPath = join(opts.cwd, '.mu', 'MEMORY.md');
8
+ const globalPath = join(opts.dataDir, 'MEMORY.md');
9
+ const readCapped = async (path) => {
10
+ const raw = await readFile(path, 'utf-8').catch(() => undefined);
11
+ if (!raw || !raw.trim())
12
+ return undefined;
13
+ return raw.length > MAX_BYTES ? raw.slice(0, MAX_BYTES) : raw.trim();
14
+ };
15
+ return {
16
+ localPath,
17
+ globalPath,
18
+ async load() {
19
+ const parts = [];
20
+ const global = await readCapped(globalPath);
21
+ if (global)
22
+ parts.push(`<!-- global -->\n${global}`);
23
+ const project = await readCapped(localPath);
24
+ if (project)
25
+ parts.push(`<!-- project -->\n${project}`);
26
+ return parts.length > 0 ? parts.join('\n\n') : undefined;
27
+ },
28
+ async remember(fact, scope) {
29
+ const path = scope === 'global' ? globalPath : localPath;
30
+ await mkdir(dirname(path), { recursive: true });
31
+ await appendFile(path, `- ${fact.trim()}\n`, 'utf-8');
32
+ },
33
+ };
34
+ }
35
+ /** A `remember` tool the agent calls to persist durable facts to local or global memory. */
36
+ export function createRememberTool(store) {
37
+ return {
38
+ name: 'remember',
39
+ description: 'Persist a durable fact to memory for future sessions (stable facts/preferences, not transient details). Scope: local = this project (default), global = all projects.',
40
+ parameters: {
41
+ type: 'object',
42
+ properties: {
43
+ fact: { type: 'string', description: 'One concise sentence to remember.' },
44
+ scope: { type: 'string', enum: ['local', 'global'], description: 'local (this project, default) or global (all projects).' },
45
+ },
46
+ required: ['fact'],
47
+ additionalProperties: false,
48
+ },
49
+ async run(input) {
50
+ const args = (input ?? {});
51
+ const fact = typeof args.fact === 'string' ? args.fact.trim() : '';
52
+ if (!fact)
53
+ return [text('Error: remember requires `fact` (string)')];
54
+ const scope = args.scope === 'global' ? 'global' : 'local';
55
+ await store.remember(fact, scope);
56
+ return [text(`Remembered (${scope}): ${fact}`)];
57
+ },
58
+ };
59
+ }
@@ -8,6 +8,7 @@ import type { Scheduler, TaskStore } from '../scheduler/index.js';
8
8
  import type { AgentSessionConfig, SessionManager } from '../session/index.js';
9
9
  import type { Skill, SkillRegistry } from '../skills/index.js';
10
10
  import type { SubAgentRegistry, SubAgentResult } from '../subAgents/index.js';
11
+ import type { CompactionOptions } from './compaction.js';
11
12
  import type { ModelRegistry } from './models.js';
12
13
  export type HarnessOptions = HarnessConfigOptions & Omit<AgentSessionConfig, 'provider' | 'model' | 'id' | 'messages'> & {
13
14
  providers: Record<string, Provider>;
@@ -24,6 +25,8 @@ export type HarnessOptions = HarnessConfigOptions & Omit<AgentSessionConfig, 'pr
24
25
  cwd?: string;
25
26
  sourceUrl?: string;
26
27
  scheduler?: boolean;
28
+ /** Auto-compaction settings, or `false` to disable. Default: enabled at 80% of the window. */
29
+ compaction?: CompactionOptions | false;
27
30
  approvals?: {
28
31
  manager: ApprovalManager;
29
32
  activeAgent: () => Agent | undefined;
@@ -42,5 +42,11 @@ export const mergeHooks = (list) => {
42
42
  return result;
43
43
  };
44
44
  }
45
+ if (hooks.some((h) => h.afterTurn)) {
46
+ merged.afterTurn = async (ctx) => {
47
+ for (const h of hooks)
48
+ await h.afterTurn?.(ctx);
49
+ };
50
+ }
45
51
  return merged;
46
52
  };
@@ -4,6 +4,16 @@ export interface PreparedRequest {
4
4
  tools?: Tool[];
5
5
  messages?: Message[];
6
6
  }
7
+ /** Context handed to `afterTurn` — inspect usage and trigger compaction without owning the message array. */
8
+ export interface AfterTurnContext {
9
+ messages: readonly Message[];
10
+ countTokens(text: string): Promise<number | undefined>;
11
+ contextWindow(): Promise<number | undefined>;
12
+ /** Summarize older messages (keeping the system + last N), replacing them in place. */
13
+ compact(opts?: {
14
+ keepLastTurns?: number;
15
+ }): Promise<void>;
16
+ }
7
17
  export interface AgentSessionHooks {
8
18
  sessionStart?(): void | Promise<void>;
9
19
  prepareRequest?(req: {
@@ -18,4 +28,6 @@ export interface AgentSessionHooks {
18
28
  name: string;
19
29
  result: ContentPart[];
20
30
  }): void | ContentPart[] | Promise<void | ContentPart[]>;
31
+ /** Fires after a turn completes (not on abort) — used for auto-compaction and memory writes. */
32
+ afterTurn?(ctx: AfterTurnContext): void | Promise<void>;
21
33
  }