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
|
@@ -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
|
|
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:
|
|
54
|
-
messages:
|
|
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,
|
package/esm/session/manager.js
CHANGED
|
@@ -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;
|
package/esm/session/persist.js
CHANGED
|
@@ -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;
|
package/esm/session/types.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/esm/tui/chat/ChatApp.js
CHANGED
|
@@ -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
|
|
437
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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.
|
|
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.
|
|
27
|
-
"mu-tui": "^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';
|
package/script/commands/index.js
CHANGED
|
@@ -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
|
+
}
|