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.
- package/esm/channels/channel.js +3 -0
- package/esm/channels/types.d.ts +3 -1
- package/esm/channels/ws/server.js +1 -1
- package/esm/commands/defaults.d.ts +9 -0
- package/esm/commands/defaults.js +127 -0
- package/esm/commands/index.d.ts +1 -1
- package/esm/commands/index.js +1 -1
- package/esm/commands/types.d.ts +3 -0
- package/esm/harness/compaction.d.ts +14 -0
- package/esm/harness/compaction.js +21 -0
- package/esm/harness/create.js +80 -3
- package/esm/harness/index.d.ts +3 -0
- package/esm/harness/index.js +3 -0
- package/esm/harness/instructions.d.ts +17 -0
- package/esm/harness/instructions.js +78 -0
- package/esm/harness/memory.d.ts +18 -0
- package/esm/harness/memory.js +59 -0
- package/esm/harness/types.d.ts +3 -0
- package/esm/hooks/merge-hooks.js +6 -0
- package/esm/hooks/types.d.ts +12 -0
- package/esm/session/agent-session.js +87 -12
- package/esm/session/manager.js +4 -0
- package/esm/session/persist.js +4 -0
- package/esm/session/types.d.ts +19 -0
- package/esm/tui/chat/ChatApp.d.ts +4 -1
- package/esm/tui/chat/ChatApp.js +22 -7
- package/package.json +3 -3
- package/script/channels/channel.js +3 -0
- package/script/channels/types.d.ts +3 -1
- package/script/channels/ws/server.js +1 -1
- package/script/commands/defaults.d.ts +9 -0
- package/script/commands/defaults.js +130 -1
- package/script/commands/index.d.ts +1 -1
- package/script/commands/index.js +3 -1
- package/script/commands/types.d.ts +3 -0
- package/script/harness/compaction.d.ts +14 -0
- package/script/harness/compaction.js +24 -0
- package/script/harness/create.js +79 -2
- package/script/harness/index.d.ts +3 -0
- package/script/harness/index.js +8 -1
- package/script/harness/instructions.d.ts +17 -0
- package/script/harness/instructions.js +82 -0
- package/script/harness/memory.d.ts +18 -0
- package/script/harness/memory.js +63 -0
- package/script/harness/types.d.ts +3 -0
- package/script/hooks/merge-hooks.js +6 -0
- package/script/hooks/types.d.ts +12 -0
- package/script/session/agent-session.js +87 -12
- package/script/session/manager.js +4 -0
- package/script/session/persist.js +4 -0
- package/script/session/types.d.ts +19 -0
- package/script/tui/chat/ChatApp.d.ts +4 -1
- package/script/tui/chat/ChatApp.js +22 -7
package/esm/channels/channel.js
CHANGED
|
@@ -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,
|
package/esm/channels/types.d.ts
CHANGED
|
@@ -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;
|
package/esm/commands/defaults.js
CHANGED
|
@@ -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',
|
package/esm/commands/index.d.ts
CHANGED
|
@@ -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';
|
package/esm/commands/index.js
CHANGED
|
@@ -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';
|
package/esm/commands/types.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/esm/harness/create.js
CHANGED
|
@@ -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([
|
|
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([
|
|
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()));
|
package/esm/harness/index.d.ts
CHANGED
|
@@ -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';
|
package/esm/harness/index.js
CHANGED
|
@@ -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
|
+
}
|
package/esm/harness/types.d.ts
CHANGED
|
@@ -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;
|
package/esm/hooks/merge-hooks.js
CHANGED
package/esm/hooks/types.d.ts
CHANGED
|
@@ -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
|
}
|