lazyclaw 5.0.9 → 5.2.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/README.md +29 -8
- package/agents.mjs +10 -8
- package/chat_window.mjs +39 -0
- package/cli.mjs +281 -56
- package/daemon.mjs +464 -11
- package/mas/agent_turn.mjs +81 -6
- package/mas/index_db.mjs +62 -2
- package/mas/learning.mjs +371 -0
- package/mas/mention_router.mjs +94 -13
- package/mas/orchestra.mjs +56 -0
- package/mas/skill_synth.mjs +36 -2
- package/mas/tool_runner.mjs +8 -1
- package/mas/tools/git.mjs +18 -1
- package/mas/tools/recall.mjs +64 -0
- package/package.json +2 -1
- package/providers/anthropic.mjs +26 -10
- package/providers/orchestrator.mjs +99 -21
- package/providers/registry.mjs +6 -0
- package/providers/tool_use/anthropic.mjs +43 -7
- package/skills.mjs +42 -3
- package/tasks.mjs +24 -1
- package/tui/repl.mjs +6 -0
- package/tui/run_turn.mjs +138 -0
- package/tui/splash.mjs +229 -32
- package/web/dashboard.html +383 -24
|
@@ -51,6 +51,13 @@ export async function callOnce({
|
|
|
51
51
|
baseUrl,
|
|
52
52
|
fetchImpl,
|
|
53
53
|
signal,
|
|
54
|
+
// Group B / C9 — prompt caching. Opt-in; the existing phase 12b/24
|
|
55
|
+
// test surface asserts the no-cache byte shape so this defaults off.
|
|
56
|
+
// Production call sites (agent_turn, mention_router) pass `cache:true`
|
|
57
|
+
// so every real Messages API call from the tool-use path gets a
|
|
58
|
+
// cache_control:ephemeral block on the static system prefix AND on
|
|
59
|
+
// the last tool object (so the tool schema cache also benefits).
|
|
60
|
+
cache = false,
|
|
54
61
|
} = {}) {
|
|
55
62
|
if (!Array.isArray(messages) || messages.length === 0) {
|
|
56
63
|
throw new AnthropicToolUseError('messages[] is required and non-empty', 'NO_MESSAGES');
|
|
@@ -65,16 +72,45 @@ export async function callOnce({
|
|
|
65
72
|
max_tokens: maxTokens,
|
|
66
73
|
messages,
|
|
67
74
|
};
|
|
68
|
-
if (system && String(system).trim())
|
|
69
|
-
|
|
75
|
+
if (system && String(system).trim()) {
|
|
76
|
+
if (cache) {
|
|
77
|
+
// Lift the system string into a one-block array carrying
|
|
78
|
+
// cache_control. The Anthropic API accepts either a plain string
|
|
79
|
+
// OR an array of text blocks here; we use the array form so we
|
|
80
|
+
// can attach the breakpoint marker.
|
|
81
|
+
body.system = [{ type: 'text', text: String(system), cache_control: { type: 'ephemeral' } }];
|
|
82
|
+
} else {
|
|
83
|
+
body.system = String(system);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (tools && tools.length) {
|
|
87
|
+
if (cache) {
|
|
88
|
+
// Clone the array (don't mutate the caller's reference) and
|
|
89
|
+
// attach cache_control to the LAST tool — that marks the entire
|
|
90
|
+
// tool block as cacheable, per Anthropic's spec.
|
|
91
|
+
const cloned = tools.map((t, i) => (i === tools.length - 1
|
|
92
|
+
? { ...t, cache_control: { type: 'ephemeral' } }
|
|
93
|
+
: t));
|
|
94
|
+
body.tools = cloned;
|
|
95
|
+
} else {
|
|
96
|
+
body.tools = tools;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const headers = {
|
|
101
|
+
'Content-Type': 'application/json',
|
|
102
|
+
'x-api-key': apiKey,
|
|
103
|
+
'anthropic-version': ANTHROPIC_VERSION,
|
|
104
|
+
};
|
|
105
|
+
if (cache) {
|
|
106
|
+
// The prompt-caching beta header. Required only on early adoption;
|
|
107
|
+
// safe to include on newer API versions where it's a no-op.
|
|
108
|
+
headers['anthropic-beta'] = 'prompt-caching-2024-07-31';
|
|
109
|
+
}
|
|
70
110
|
|
|
71
111
|
const res = await fetchFn(url, {
|
|
72
112
|
method: 'POST',
|
|
73
|
-
headers
|
|
74
|
-
'Content-Type': 'application/json',
|
|
75
|
-
'x-api-key': apiKey,
|
|
76
|
-
'anthropic-version': ANTHROPIC_VERSION,
|
|
77
|
-
},
|
|
113
|
+
headers,
|
|
78
114
|
body: JSON.stringify(body),
|
|
79
115
|
signal,
|
|
80
116
|
});
|
package/skills.mjs
CHANGED
|
@@ -15,6 +15,22 @@ import os from 'node:os';
|
|
|
15
15
|
const SKILLS_DIRNAME = 'skills';
|
|
16
16
|
const SKILL_EXT = '.md';
|
|
17
17
|
|
|
18
|
+
// Group B / M8 — memoize listSkills() + skillsIndex() per configDir.
|
|
19
|
+
// composePromptStack is called on every chat/agent turn; listing the
|
|
20
|
+
// skills directory + statting + reading every file used to cost ~2-5 ms
|
|
21
|
+
// at ~30 skills, and was the largest non-network item in the per-turn
|
|
22
|
+
// flame graph. Cache key is the skills/ directory's mtimeMs — any
|
|
23
|
+
// install/remove via this module bumps it (writeFile updates the
|
|
24
|
+
// containing dir's mtime). Manual edits with `vim` also bust the cache
|
|
25
|
+
// because writing a file in a directory updates that dir's mtime.
|
|
26
|
+
// _invalidateSkillsCache() is exported as the safety hatch for callers
|
|
27
|
+
// that want to be explicit.
|
|
28
|
+
const _indexCache = new Map(); // cfgDir → { mtimeMs, skills, index }
|
|
29
|
+
|
|
30
|
+
export function _invalidateSkillsCache(configDir = defaultConfigDir()) {
|
|
31
|
+
_indexCache.delete(configDir);
|
|
32
|
+
}
|
|
33
|
+
|
|
18
34
|
export function defaultConfigDir() {
|
|
19
35
|
return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
|
|
20
36
|
}
|
|
@@ -33,7 +49,14 @@ export function skillPath(name, configDir = defaultConfigDir()) {
|
|
|
33
49
|
export function listSkills(configDir = defaultConfigDir()) {
|
|
34
50
|
const dir = skillsDir(configDir);
|
|
35
51
|
if (!fs.existsSync(dir)) return [];
|
|
36
|
-
|
|
52
|
+
// M8 — cache hit when the skills/ directory's mtime is unchanged.
|
|
53
|
+
let dirMtime = 0;
|
|
54
|
+
try { dirMtime = fs.statSync(dir).mtimeMs; } catch { /* unreadable → bypass cache */ }
|
|
55
|
+
const cached = _indexCache.get(configDir);
|
|
56
|
+
if (cached && cached.mtimeMs === dirMtime && dirMtime > 0) {
|
|
57
|
+
return cached.skills;
|
|
58
|
+
}
|
|
59
|
+
const skills = fs.readdirSync(dir)
|
|
37
60
|
.filter(name => name.endsWith(SKILL_EXT))
|
|
38
61
|
.map(name => {
|
|
39
62
|
const full = path.join(dir, name);
|
|
@@ -57,6 +80,10 @@ export function listSkills(configDir = defaultConfigDir()) {
|
|
|
57
80
|
};
|
|
58
81
|
})
|
|
59
82
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
83
|
+
if (dirMtime > 0) {
|
|
84
|
+
_indexCache.set(configDir, { mtimeMs: dirMtime, skills, index: null });
|
|
85
|
+
}
|
|
86
|
+
return skills;
|
|
60
87
|
}
|
|
61
88
|
|
|
62
89
|
// Parse a leading YAML frontmatter block (--- … ---). Only the flat
|
|
@@ -108,9 +135,19 @@ function firstHeading(body) {
|
|
|
108
135
|
// paying for their full bodies (progressive disclosure — the model
|
|
109
136
|
// pulls a full skill on demand via the skill_view tool).
|
|
110
137
|
export function skillsIndex(configDir = defaultConfigDir()) {
|
|
138
|
+
// Memoize the rendered index alongside the cached skills array — both
|
|
139
|
+
// are pure functions of the skills/ mtime.
|
|
140
|
+
const cached = _indexCache.get(configDir);
|
|
141
|
+
if (cached && cached.index !== null) return cached.index;
|
|
111
142
|
const skills = listSkills(configDir);
|
|
112
|
-
if (!skills.length)
|
|
113
|
-
|
|
143
|
+
if (!skills.length) {
|
|
144
|
+
if (cached) cached.index = '';
|
|
145
|
+
return '';
|
|
146
|
+
}
|
|
147
|
+
const index = skills.map((s) => `- ${s.name}: ${s.summary}`.trimEnd()).join('\n');
|
|
148
|
+
const refreshed = _indexCache.get(configDir);
|
|
149
|
+
if (refreshed) refreshed.index = index;
|
|
150
|
+
return index;
|
|
114
151
|
}
|
|
115
152
|
|
|
116
153
|
export function loadSkill(name, configDir = defaultConfigDir()) {
|
|
@@ -123,12 +160,14 @@ export function installSkill(name, content, configDir = defaultConfigDir()) {
|
|
|
123
160
|
const p = skillPath(name, configDir);
|
|
124
161
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
125
162
|
fs.writeFileSync(p, content);
|
|
163
|
+
_invalidateSkillsCache(configDir);
|
|
126
164
|
return p;
|
|
127
165
|
}
|
|
128
166
|
|
|
129
167
|
export function removeSkill(name, configDir = defaultConfigDir()) {
|
|
130
168
|
const p = skillPath(name, configDir);
|
|
131
169
|
if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
170
|
+
_invalidateSkillsCache(configDir);
|
|
132
171
|
}
|
|
133
172
|
|
|
134
173
|
export function skillExists(name, configDir = defaultConfigDir()) {
|
package/tasks.mjs
CHANGED
|
@@ -157,7 +157,30 @@ export function appendTurn(id, turn, configDir = defaultConfigDir()) {
|
|
|
157
157
|
const t = getTask(id, configDir);
|
|
158
158
|
if (!t) throw new TaskError(`no task "${id}"`, 'TASK_NO_TASK');
|
|
159
159
|
const turns = Array.isArray(t.turns) ? [...t.turns, turn] : [turn];
|
|
160
|
-
|
|
160
|
+
const next = patchTask(id, { turns }, configDir);
|
|
161
|
+
// v5 Group A (M4): mirror the appended turn to the FTS5 sessions
|
|
162
|
+
// index using session_id = `task:<id>` so the recall tool can surface
|
|
163
|
+
// task transcripts the same way it surfaces chat sessions. Namespaced
|
|
164
|
+
// with the `task:` prefix to avoid colliding with chat session ids.
|
|
165
|
+
// Best-effort: any FTS failure stays inside the dynamic-import block
|
|
166
|
+
// so a missing index_db (e.g. in a stripped test env) never breaks
|
|
167
|
+
// task writes.
|
|
168
|
+
try {
|
|
169
|
+
void (async () => {
|
|
170
|
+
try {
|
|
171
|
+
const { indexSessionTurn } = await import('./mas/index_db.mjs');
|
|
172
|
+
const turnIdx = turns.length - 1;
|
|
173
|
+
indexSessionTurn({
|
|
174
|
+
session_id: `task:${id}`,
|
|
175
|
+
turn_idx: turnIdx,
|
|
176
|
+
role: turn.agent === 'user' ? 'user' : 'assistant',
|
|
177
|
+
ts: Date.parse(turn.ts) || Date.now(),
|
|
178
|
+
content: turn.text || '',
|
|
179
|
+
}, configDir);
|
|
180
|
+
} catch { /* swallow */ }
|
|
181
|
+
})();
|
|
182
|
+
} catch { /* swallow */ }
|
|
183
|
+
return next;
|
|
161
184
|
}
|
|
162
185
|
|
|
163
186
|
export function removeTask(id, configDir = defaultConfigDir()) {
|
package/tui/repl.mjs
CHANGED
|
@@ -56,6 +56,12 @@ export function consumeNextTurnFirstMessage(state) {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
// ─── React mount ─────────────────────────────────────────────────────────
|
|
59
|
+
// v5.1 TODO (C7 follow-up): replace direct stdout writes in cli.mjs's
|
|
60
|
+
// _chatRunTurnFactory with a scrollback ref'd via this component. The
|
|
61
|
+
// shape would be an `onOutput` prop that pushes chunks into a `useState`
|
|
62
|
+
// array rendered through Ink's <Static items={scrollback}/>. v5.0.10
|
|
63
|
+
// ships with raw stdout writes interleaved with Ink (acceptable visual
|
|
64
|
+
// jank in exchange for unblocking the chat loop).
|
|
59
65
|
export function ReplApp({ splashProps, runTurn }) {
|
|
60
66
|
const [state, setState] = useState(makeReplState);
|
|
61
67
|
const { exit } = useApp();
|
package/tui/run_turn.mjs
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// tui/run_turn.mjs — shared chat-turn streaming closure (v5 Group C, C7).
|
|
2
|
+
//
|
|
3
|
+
// Single source of truth for the streaming + persist + post-task
|
|
4
|
+
// learning loop. The same factory backs:
|
|
5
|
+
// - the ink REPL path (ReplApp.runTurn)
|
|
6
|
+
// - the legacy readline path (cli.mjs's `for await (const line of rl)` body)
|
|
7
|
+
//
|
|
8
|
+
// One factory ⇒ one set of bugs. Both call sites get the same buffered
|
|
9
|
+
// writer (CJK-safe 30 ms coalescing), the same persistTurn dual-write
|
|
10
|
+
// (sessions + memory/recent), and the same fire-and-forget learning
|
|
11
|
+
// hook (queueMicrotask).
|
|
12
|
+
//
|
|
13
|
+
// `ctx` carries the chat-session state. Getter functions close over
|
|
14
|
+
// *current* bindings so a mid-session /provider switch takes effect on
|
|
15
|
+
// the very next turn without re-creating the closure.
|
|
16
|
+
//
|
|
17
|
+
// `writeFn` is the sink for streamed chunks — the legacy path passes
|
|
18
|
+
// `(s) => process.stdout.write(s)`; the ink path also writes to stdout
|
|
19
|
+
// for v5.0.10 (interleaves with Ink output; visual jank accepted in
|
|
20
|
+
// exchange for unblocking the chat loop). v5.1 TODO: route the ink
|
|
21
|
+
// writeFn through a scrollback ref in ReplApp.
|
|
22
|
+
//
|
|
23
|
+
// Errors are swallowed (matches v4 legacy behavior) — we write
|
|
24
|
+
// "error: ..." into writeFn but do not re-throw, so the caller's
|
|
25
|
+
// turn-completion logic (ReplApp onTurnComplete, legacy `rl.prompt`)
|
|
26
|
+
// runs unconditionally.
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {Object} RunTurnCtx
|
|
30
|
+
* @property {Object} cfg Active config.
|
|
31
|
+
* @property {string} cfgDir Resolved configDir.
|
|
32
|
+
* @property {Object|null} sandboxSpec Parsed --sandbox spec (or null).
|
|
33
|
+
* @property {string} syntheticChatSessionId Session-id fallback when --session not set.
|
|
34
|
+
* @property {() => Array<{role:string,content:string}>} getMessages
|
|
35
|
+
* @property {() => { sendMessage: Function, name?: string }} getProv
|
|
36
|
+
* @property {() => string} getActiveProvName
|
|
37
|
+
* @property {() => string|null} getActiveModel
|
|
38
|
+
* @property {() => string|null} getSessionId
|
|
39
|
+
* @property {(role: string, content: string) => void} persistTurn
|
|
40
|
+
* @property {(u: any) => void} accumulateUsage
|
|
41
|
+
* @property {(provider: string) => string} resolveAuthKey Caller supplies; mirrors cli.mjs::_resolveAuthKey.
|
|
42
|
+
* @property {(n: number) => void} [onCharsSent] Optional observer (legacy path increments charsSent).
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build a runTurn(text, signal) closure that drives one provider turn
|
|
47
|
+
* end-to-end.
|
|
48
|
+
*
|
|
49
|
+
* @param {{ ctx: RunTurnCtx, writeFn: (chunk: string) => void }} args
|
|
50
|
+
* @returns {(text: string, signal?: AbortSignal) => Promise<void>}
|
|
51
|
+
*/
|
|
52
|
+
export function makeRunTurn({ ctx, writeFn }) {
|
|
53
|
+
return async function runTurn(text, signal) {
|
|
54
|
+
if (signal?.aborted) return;
|
|
55
|
+
const messages = ctx.getMessages();
|
|
56
|
+
messages.push({ role: 'user', content: text });
|
|
57
|
+
try { ctx.onCharsSent && ctx.onCharsSent(text.length); }
|
|
58
|
+
catch { /* observer is best-effort */ }
|
|
59
|
+
ctx.persistTurn('user', text);
|
|
60
|
+
|
|
61
|
+
let acc = '';
|
|
62
|
+
let _writeBuf = '';
|
|
63
|
+
let _writeTimer = null;
|
|
64
|
+
const _flush = () => {
|
|
65
|
+
if (_writeBuf) {
|
|
66
|
+
try { writeFn(_writeBuf); }
|
|
67
|
+
catch { /* sink failure must not kill the turn */ }
|
|
68
|
+
_writeBuf = '';
|
|
69
|
+
}
|
|
70
|
+
_writeTimer = null;
|
|
71
|
+
};
|
|
72
|
+
const _writeChunk = (s) => {
|
|
73
|
+
_writeBuf += s;
|
|
74
|
+
if (!_writeTimer) _writeTimer = setTimeout(_flush, 30);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const sysMsg = messages.find((m) => m.role === 'system');
|
|
79
|
+
const prov = ctx.getProv();
|
|
80
|
+
const activeProvName = ctx.getActiveProvName();
|
|
81
|
+
const activeModel = ctx.getActiveModel();
|
|
82
|
+
// C8 — prompt-cache the static system prefix. The Anthropic
|
|
83
|
+
// provider prefers `systemStatic` when present; non-Anthropic
|
|
84
|
+
// providers ignore the field and fall back to the legacy
|
|
85
|
+
// single-block path with `cache:true`.
|
|
86
|
+
for await (const chunk of prov.sendMessage(messages, {
|
|
87
|
+
apiKey: ctx.resolveAuthKey(activeProvName),
|
|
88
|
+
model: activeModel,
|
|
89
|
+
sandbox: ctx.sandboxSpec,
|
|
90
|
+
signal,
|
|
91
|
+
onUsage: ctx.accumulateUsage,
|
|
92
|
+
cache: true,
|
|
93
|
+
...(sysMsg ? { systemStatic: sysMsg.content } : {}),
|
|
94
|
+
})) {
|
|
95
|
+
_writeChunk(chunk);
|
|
96
|
+
acc += chunk;
|
|
97
|
+
}
|
|
98
|
+
if (_writeTimer) clearTimeout(_writeTimer);
|
|
99
|
+
_flush();
|
|
100
|
+
try { writeFn('\n'); }
|
|
101
|
+
catch { /* sink failure must not kill the turn */ }
|
|
102
|
+
messages.push({ role: 'assistant', content: acc });
|
|
103
|
+
ctx.persistTurn('assistant', acc);
|
|
104
|
+
// v5 Group A (C1): close the post-task learning loop on every
|
|
105
|
+
// successful chat turn. Fire-and-forget via queueMicrotask so the
|
|
106
|
+
// next prompt is never blocked on trajectory write / synth.
|
|
107
|
+
try {
|
|
108
|
+
queueMicrotask(() => {
|
|
109
|
+
import('../mas/learning.mjs').then((mod) => mod.runLearning('post-task', {
|
|
110
|
+
agent: { name: 'chat', provider: activeProvName, model: activeModel, role: '' },
|
|
111
|
+
task: {
|
|
112
|
+
id: ctx.getSessionId() || ctx.syntheticChatSessionId,
|
|
113
|
+
title: '(chat turn)',
|
|
114
|
+
turns: messages.slice(-2).map((m) => ({
|
|
115
|
+
agent: m.role === 'user' ? 'user' : 'chat',
|
|
116
|
+
text: m.content,
|
|
117
|
+
ts: new Date().toISOString(),
|
|
118
|
+
})),
|
|
119
|
+
},
|
|
120
|
+
configDir: ctx.cfgDir,
|
|
121
|
+
cfg: ctx.cfg,
|
|
122
|
+
transcript: acc.slice(0, 8000),
|
|
123
|
+
})).catch(() => { /* learning loop is best-effort */ });
|
|
124
|
+
});
|
|
125
|
+
} catch { /* never let learning hook break the chat */ }
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (_writeTimer) clearTimeout(_writeTimer);
|
|
128
|
+
_flush();
|
|
129
|
+
// ABORT errors are user-initiated; drop the partial reply (don't
|
|
130
|
+
// push an incomplete assistant message — next turn would treat
|
|
131
|
+
// it as a complete reply and confuse the model).
|
|
132
|
+
if (err?.code !== 'ABORT' && !signal?.aborted) {
|
|
133
|
+
try { writeFn(`error: ${err?.message || String(err)}\n`); }
|
|
134
|
+
catch { /* sink failure must not mask err */ }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
}
|
package/tui/splash.mjs
CHANGED
|
@@ -1,29 +1,12 @@
|
|
|
1
1
|
// tui/splash.mjs — Hermes-style hero splash with gradient wordmark,
|
|
2
2
|
// subcommand catalog, tool registry, skill index, and a bottom status bar.
|
|
3
3
|
//
|
|
4
|
-
// Layout (terminal-width responsive):
|
|
4
|
+
// Layout (terminal-width responsive across four tiers):
|
|
5
5
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// │ core / workflow / config / state / ... │
|
|
11
|
-
// │ Available Tools │
|
|
12
|
-
// │ fs / exec / git / ... │
|
|
13
|
-
// │ Available Skills │
|
|
14
|
-
// │ N subcommands · M tool groups · K skills · /help │
|
|
15
|
-
// ╰──────────────────────────────────────────────────────────────────────╯
|
|
16
|
-
//
|
|
17
|
-
// provider · X · Y · trainer · A · B
|
|
18
|
-
// /cwd
|
|
19
|
-
// Session: ...
|
|
20
|
-
//
|
|
21
|
-
// Welcome to lazyclaw. Type your message or /help for commands.
|
|
22
|
-
// + Tip: ...
|
|
23
|
-
//
|
|
24
|
-
// ───────────────────────────────────────────────────────────────────────
|
|
25
|
-
// provider · model | ctx -- | [progress] | 0s
|
|
26
|
-
// ───────────────────────────────────────────────────────────────────────
|
|
6
|
+
// WIDE (cols >= 140) — full wordmark + panel + sloth + 2-col right side
|
|
7
|
+
// MEDIUM ( 90 <= cols < 140) — compact headline, panel + sloth + wrapped right column
|
|
8
|
+
// NARROW ( 60 <= cols < 90) — single column, no sloth/panel, truncated verb lists
|
|
9
|
+
// MINIMAL (cols < 60) — headline + provider + cwd + /help line only
|
|
27
10
|
import React from 'react';
|
|
28
11
|
import { Box, Text } from 'ink';
|
|
29
12
|
import stringWidth from 'string-width';
|
|
@@ -34,6 +17,14 @@ import { wordmark } from './wordmark.mjs';
|
|
|
34
17
|
const LMARGIN = ' ';
|
|
35
18
|
const TITLE = ' trainer-split · FTS5 recall · 6-backend sandbox ';
|
|
36
19
|
|
|
20
|
+
// Tier breakpoints. Wordmark is 120 cols wide + LMARGIN(2)*2 = 124 minimum;
|
|
21
|
+
// the user constraint pins WIDE at >=140 to give comfortable slack. Below
|
|
22
|
+
// 90 the sloth (48 cols) leaves <40 cols for the right column, so we drop
|
|
23
|
+
// it and go single-column. Below 60 we emit only a minimal headline.
|
|
24
|
+
const WORDMARK_BREAKPOINT = 140; // drop wordmark below this
|
|
25
|
+
const PANEL_BREAKPOINT = 90; // drop sloth+panel below this
|
|
26
|
+
const MINIMAL_BREAKPOINT = 60; // drop everything but headline below this
|
|
27
|
+
|
|
37
28
|
// Subcommand catalog — grouped for the splash so a new user sees the
|
|
38
29
|
// surface area at a glance. Mirrors SUBCOMMANDS in cli.mjs.
|
|
39
30
|
export const SUBCOMMAND_GROUPS = [
|
|
@@ -79,10 +70,48 @@ function subcommandRow([label, verbs]) {
|
|
|
79
70
|
return `${label.padEnd(12)} ${verbs.join(' · ')}`;
|
|
80
71
|
}
|
|
81
72
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
73
|
+
// Wrap a labeled verb list onto multiple rows when it would overflow maxWidth.
|
|
74
|
+
// First row: '<label.padEnd(12)> verb · verb'; continuations: ' verb · verb'.
|
|
75
|
+
function wrapVerbs(label, verbs, maxWidth) {
|
|
76
|
+
const pad = ' '.repeat(13); // label.padEnd(12) + ' ' = 13 cells
|
|
77
|
+
const rows = [];
|
|
78
|
+
let current = label.padEnd(12) + ' ';
|
|
79
|
+
let firstOnRow = true;
|
|
80
|
+
for (const v of verbs) {
|
|
81
|
+
const candidate = firstOnRow ? current + v : current + ' · ' + v;
|
|
82
|
+
if (stringWidth(candidate) > maxWidth) {
|
|
83
|
+
if (firstOnRow) {
|
|
84
|
+
// even a single verb overflows — emit it anyway (truncated) to make progress.
|
|
85
|
+
rows.push(fit(candidate, maxWidth).trimEnd());
|
|
86
|
+
current = pad;
|
|
87
|
+
firstOnRow = true;
|
|
88
|
+
} else {
|
|
89
|
+
rows.push(current.trimEnd());
|
|
90
|
+
current = pad + v;
|
|
91
|
+
firstOnRow = false;
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
current = candidate;
|
|
95
|
+
firstOnRow = false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (current.trim()) rows.push(current.trimEnd());
|
|
99
|
+
return rows;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Crush-style truncation for NARROW tier — take first N verbs, append '…' if more.
|
|
103
|
+
function truncateRow(label, verbs, maxWidth, take = 3) {
|
|
104
|
+
const head = label.padEnd(12) + ' ';
|
|
105
|
+
const tail = verbs.slice(0, take).join(' · ');
|
|
106
|
+
let line = head + tail;
|
|
107
|
+
if (verbs.length > take) line += ' …';
|
|
108
|
+
if (stringWidth(line) <= maxWidth) return line;
|
|
109
|
+
return fit(line, maxWidth).trimEnd();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Wide tier — original v5.0.9 layout, kept verbatim.
|
|
113
|
+
function renderWide(props, cols) {
|
|
114
|
+
const PANEL_W = cols - LMARGIN.length * 2;
|
|
86
115
|
const INNER = PANEL_W - 4;
|
|
87
116
|
const SLOTH_W = banner.width;
|
|
88
117
|
const RIGHT_W = Math.max(40, INNER - SLOTH_W - 2);
|
|
@@ -99,7 +128,7 @@ export function renderSplashToString(props, opts = {}) {
|
|
|
99
128
|
const dashRight = '─'.repeat(Math.max(2, PANEL_W - 2 - dashLeft.length - stringWidth(versionLabel)));
|
|
100
129
|
lines.push(`${LMARGIN}╭${dashLeft}${versionLabel}${dashRight}╮`);
|
|
101
130
|
|
|
102
|
-
// 3) right column content
|
|
131
|
+
// 3) right column content
|
|
103
132
|
const { tools = [], skills = [] } = props;
|
|
104
133
|
const right = [];
|
|
105
134
|
right.push('Subcommands');
|
|
@@ -143,7 +172,97 @@ export function renderSplashToString(props, opts = {}) {
|
|
|
143
172
|
lines.push(`${LMARGIN}+ Tip: trainer learns from your Claude Pro subscription at $0.`);
|
|
144
173
|
lines.push('');
|
|
145
174
|
|
|
146
|
-
// 5)
|
|
175
|
+
// 5) status bar (Hermes-style separator)
|
|
176
|
+
const sep = '─'.repeat(PANEL_W);
|
|
177
|
+
lines.push(LMARGIN + sep);
|
|
178
|
+
const ctx = props.ctxUsed != null && props.ctxTotal != null
|
|
179
|
+
? `[${'░'.repeat(20)}] ${props.ctxUsed}/${props.ctxTotal}`
|
|
180
|
+
: `[${'░'.repeat(20)}] --`;
|
|
181
|
+
lines.push(`${LMARGIN} ${provider} · ${model} | ctx -- | ${ctx} | 0s`);
|
|
182
|
+
lines.push(LMARGIN + sep);
|
|
183
|
+
|
|
184
|
+
return lines;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Medium tier — no wordmark, compact panel title, sloth + wrapped right column.
|
|
188
|
+
function renderMedium(props, cols) {
|
|
189
|
+
const PANEL_W = cols - LMARGIN.length * 2;
|
|
190
|
+
const INNER = PANEL_W - 4;
|
|
191
|
+
const SLOTH_W = banner.width;
|
|
192
|
+
const RIGHT_W = Math.max(20, INNER - SLOTH_W - 2);
|
|
193
|
+
|
|
194
|
+
const lines = [];
|
|
195
|
+
|
|
196
|
+
// 1) compact headline (no wordmark)
|
|
197
|
+
lines.push(`${LMARGIN}lazyclaw ${props.version || ''}`.trimEnd());
|
|
198
|
+
lines.push('');
|
|
199
|
+
|
|
200
|
+
// 2) compact panel top — drop the TITLE chain, just version
|
|
201
|
+
const versionLabel = ` lazyclaw ${props.version || ''} `;
|
|
202
|
+
const dashLeft = '─'.repeat(4);
|
|
203
|
+
const dashRight = '─'.repeat(Math.max(2, PANEL_W - 2 - dashLeft.length - stringWidth(versionLabel)));
|
|
204
|
+
lines.push(`${LMARGIN}╭${dashLeft}${versionLabel}${dashRight}╮`);
|
|
205
|
+
|
|
206
|
+
// 3) build right column with wrapping
|
|
207
|
+
const { tools = [], skills = [] } = props;
|
|
208
|
+
const right = [];
|
|
209
|
+
right.push('Subcommands');
|
|
210
|
+
for (const [label, verbs] of SUBCOMMAND_GROUPS) {
|
|
211
|
+
for (const r of wrapVerbs(label, verbs, RIGHT_W)) right.push(r);
|
|
212
|
+
}
|
|
213
|
+
right.push('');
|
|
214
|
+
right.push('Available Tools');
|
|
215
|
+
for (const t of tools.slice(0, 14)) {
|
|
216
|
+
const label = t.sensitive ? `${t.category}*` : t.category;
|
|
217
|
+
for (const r of wrapVerbs(label, t.verbs.slice(0, 6), RIGHT_W)) right.push(r);
|
|
218
|
+
}
|
|
219
|
+
if (tools.length > 14) right.push(`(and ${tools.length - 14} more...)`);
|
|
220
|
+
right.push('');
|
|
221
|
+
right.push('Available Skills');
|
|
222
|
+
if (skills.length === 0) right.push('(none installed)');
|
|
223
|
+
else {
|
|
224
|
+
for (const s of skills.slice(0, 8)) {
|
|
225
|
+
for (const r of wrapVerbs(s.group, s.names.slice(0, 6), RIGHT_W)) right.push(r);
|
|
226
|
+
}
|
|
227
|
+
if (skills.length > 8) right.push(`(and ${skills.length - 8} more skill groups...)`);
|
|
228
|
+
}
|
|
229
|
+
right.push('');
|
|
230
|
+
const subcmdCount = SUBCOMMAND_GROUPS.reduce((n, [, v]) => n + v.length, 0);
|
|
231
|
+
const summary = `${subcmdCount} subcommands · ${tools.length} tool groups · ${skills.length} skills · /help`;
|
|
232
|
+
if (stringWidth(summary) > RIGHT_W) {
|
|
233
|
+
right.push(`${subcmdCount} subcmds · ${tools.length} tools · ${skills.length} skills`);
|
|
234
|
+
right.push('/help for commands');
|
|
235
|
+
} else {
|
|
236
|
+
right.push(summary);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const sloth = banner.rows.slice();
|
|
240
|
+
while (sloth.length < right.length) sloth.push(' '.repeat(SLOTH_W));
|
|
241
|
+
while (right.length < sloth.length) right.push('');
|
|
242
|
+
|
|
243
|
+
for (let i = 0; i < sloth.length; i++) {
|
|
244
|
+
const l = sloth[i] || ' '.repeat(SLOTH_W);
|
|
245
|
+
// pad (no ellipsis) — wrapVerbs already guarantees width <= RIGHT_W
|
|
246
|
+
const raw = right[i] || '';
|
|
247
|
+
const r = raw + ' '.repeat(Math.max(0, RIGHT_W - stringWidth(raw)));
|
|
248
|
+
lines.push(`${LMARGIN}│ ${l} ${r} │`);
|
|
249
|
+
}
|
|
250
|
+
lines.push(`${LMARGIN}╰${'─'.repeat(PANEL_W - 2)}╯`);
|
|
251
|
+
lines.push('');
|
|
252
|
+
|
|
253
|
+
// 4) provider / session info
|
|
254
|
+
const { provider, model, trainer = {}, sessionId, cwd } = props;
|
|
255
|
+
const tProv = trainer.provider || provider;
|
|
256
|
+
const tModel = trainer.model || model;
|
|
257
|
+
lines.push(`${LMARGIN}${provider} · ${model} · trainer ${tProv} · ${tModel}`);
|
|
258
|
+
lines.push(`${LMARGIN}${shortCwd(cwd || process.cwd())}`);
|
|
259
|
+
if (sessionId) lines.push(`${LMARGIN}Session: ${sessionId}`);
|
|
260
|
+
lines.push('');
|
|
261
|
+
lines.push(`${LMARGIN}Welcome to lazyclaw. Type your message or /help for commands.`);
|
|
262
|
+
lines.push(`${LMARGIN}+ Tip: trainer learns from your Claude Pro subscription at $0.`);
|
|
263
|
+
lines.push('');
|
|
264
|
+
|
|
265
|
+
// 5) status bar
|
|
147
266
|
const sep = '─'.repeat(PANEL_W);
|
|
148
267
|
lines.push(LMARGIN + sep);
|
|
149
268
|
const ctx = props.ctxUsed != null && props.ctxTotal != null
|
|
@@ -152,25 +271,103 @@ export function renderSplashToString(props, opts = {}) {
|
|
|
152
271
|
lines.push(`${LMARGIN} ${provider} · ${model} | ctx -- | ${ctx} | 0s`);
|
|
153
272
|
lines.push(LMARGIN + sep);
|
|
154
273
|
|
|
274
|
+
return lines;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Narrow tier — single column, no sloth, no panel, truncated verb lists.
|
|
278
|
+
function renderNarrow(props, cols) {
|
|
279
|
+
const W = cols - LMARGIN.length * 2;
|
|
280
|
+
const lines = [];
|
|
281
|
+
|
|
282
|
+
// 1) headline
|
|
283
|
+
lines.push(`${LMARGIN}lazyclaw ${props.version || ''}`.trimEnd());
|
|
284
|
+
lines.push('');
|
|
285
|
+
|
|
286
|
+
const { tools = [], skills = [], provider, model, trainer = {}, sessionId, cwd } = props;
|
|
287
|
+
|
|
288
|
+
// 2) subcommands
|
|
289
|
+
lines.push(`${LMARGIN}Subcommands`);
|
|
290
|
+
for (const [label, verbs] of SUBCOMMAND_GROUPS) {
|
|
291
|
+
lines.push(LMARGIN + truncateRow(label, verbs, W));
|
|
292
|
+
}
|
|
293
|
+
lines.push('');
|
|
294
|
+
|
|
295
|
+
// 3) tools
|
|
296
|
+
lines.push(`${LMARGIN}Available Tools`);
|
|
297
|
+
for (const t of tools.slice(0, 14)) {
|
|
298
|
+
const label = t.sensitive ? `${t.category}*` : t.category;
|
|
299
|
+
lines.push(LMARGIN + truncateRow(label, t.verbs, W));
|
|
300
|
+
}
|
|
301
|
+
if (tools.length > 14) lines.push(`${LMARGIN}(and ${tools.length - 14} more...)`);
|
|
302
|
+
lines.push('');
|
|
303
|
+
|
|
304
|
+
// 4) skills
|
|
305
|
+
lines.push(`${LMARGIN}Available Skills`);
|
|
306
|
+
if (skills.length === 0) lines.push(`${LMARGIN}(none installed)`);
|
|
307
|
+
else {
|
|
308
|
+
for (const s of skills.slice(0, 8)) {
|
|
309
|
+
lines.push(LMARGIN + truncateRow(s.group, s.names, W));
|
|
310
|
+
}
|
|
311
|
+
if (skills.length > 8) lines.push(`${LMARGIN}(and ${skills.length - 8} more skill groups...)`);
|
|
312
|
+
}
|
|
313
|
+
lines.push('');
|
|
314
|
+
|
|
315
|
+
// 5) provider / session info
|
|
316
|
+
const tProv = trainer.provider || provider;
|
|
317
|
+
const tModel = trainer.model || model;
|
|
318
|
+
lines.push(fit(`${LMARGIN}${provider} · ${model} · trainer ${tProv} · ${tModel}`, cols).trimEnd());
|
|
319
|
+
lines.push(fit(`${LMARGIN}${shortCwd(cwd || process.cwd())}`, cols).trimEnd());
|
|
320
|
+
if (sessionId) lines.push(fit(`${LMARGIN}Session: ${sessionId}`, cols).trimEnd());
|
|
321
|
+
lines.push('');
|
|
322
|
+
lines.push(fit(`${LMARGIN}Welcome to lazyclaw. /help for commands.`, cols).trimEnd());
|
|
323
|
+
lines.push('');
|
|
324
|
+
|
|
325
|
+
// 6) compact status — single line, no separator dashes
|
|
326
|
+
const ctx = props.ctxUsed != null && props.ctxTotal != null
|
|
327
|
+
? `${props.ctxUsed}/${props.ctxTotal}`
|
|
328
|
+
: '--';
|
|
329
|
+
lines.push(fit(`${LMARGIN}${provider} · ${model} · ctx ${ctx}`, cols).trimEnd());
|
|
330
|
+
|
|
331
|
+
return lines;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Minimal tier — bare-bones fallback for cols < 60.
|
|
335
|
+
function renderMinimal(props) {
|
|
336
|
+
const { version, provider, model, sessionId, cwd } = props;
|
|
337
|
+
const lines = [];
|
|
338
|
+
lines.push(`lazyclaw ${version || ''}`.trimEnd());
|
|
339
|
+
lines.push(`${provider} · ${model}`);
|
|
340
|
+
lines.push(shortCwd(cwd || process.cwd()));
|
|
341
|
+
if (sessionId) lines.push(`Session: ${sessionId}`);
|
|
342
|
+
lines.push('/help for commands');
|
|
343
|
+
return lines;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function renderSplashToString(props, opts = {}) {
|
|
347
|
+
const cols = opts.columns || process.stdout.columns || 100;
|
|
348
|
+
let lines;
|
|
349
|
+
if (cols < MINIMAL_BREAKPOINT) lines = renderMinimal(props);
|
|
350
|
+
else if (cols < PANEL_BREAKPOINT) lines = renderNarrow(props, cols);
|
|
351
|
+
else if (cols < WORDMARK_BREAKPOINT) lines = renderMedium(props, cols);
|
|
352
|
+
else lines = renderWide(props, cols);
|
|
155
353
|
return lines.join('\n');
|
|
156
354
|
}
|
|
157
355
|
|
|
158
356
|
export function Splash(props) {
|
|
159
357
|
const cols = process.stdout.columns || 100;
|
|
160
|
-
const TERM = Math.max(80, cols);
|
|
161
|
-
const PANEL_W = TERM - LMARGIN.length * 2;
|
|
162
358
|
const out = renderSplashToString(props, { columns: cols });
|
|
163
359
|
const lines = out.split('\n');
|
|
164
360
|
const palette = wordmark.palette;
|
|
165
361
|
const gradient = wordmark.gradient;
|
|
362
|
+
const showWordmark = cols >= WORDMARK_BREAKPOINT;
|
|
166
363
|
|
|
167
364
|
return React.createElement(
|
|
168
365
|
Box,
|
|
169
366
|
{ flexDirection: 'column' },
|
|
170
367
|
lines.map((line, i) => {
|
|
171
368
|
let color;
|
|
172
|
-
if (i < wordmark.height) color = palette[gradient[i] ?? 1];
|
|
173
|
-
else if (i < wordmark.height + 1 + 1 + banner.height) color = theme.fg;
|
|
369
|
+
if (showWordmark && i < wordmark.height) color = palette[gradient[i] ?? 1];
|
|
370
|
+
else if (showWordmark && i < wordmark.height + 1 + 1 + banner.height) color = theme.fg;
|
|
174
371
|
return React.createElement(Text, { key: i, color }, line);
|
|
175
372
|
})
|
|
176
373
|
);
|