@zhijiewang/openharness 2.4.0 → 2.8.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 +2 -2
- package/dist/Tool.d.ts +2 -0
- package/dist/commands/ai.d.ts +6 -0
- package/dist/commands/ai.js +244 -0
- package/dist/commands/git.d.ts +6 -0
- package/dist/commands/git.js +167 -0
- package/dist/commands/index.d.ts +10 -31
- package/dist/commands/index.js +22 -1052
- package/dist/commands/info.d.ts +8 -0
- package/dist/commands/info.js +671 -0
- package/dist/commands/session.d.ts +6 -0
- package/dist/commands/session.js +214 -0
- package/dist/commands/settings.d.ts +6 -0
- package/dist/commands/settings.js +187 -0
- package/dist/commands/skills.d.ts +6 -0
- package/dist/commands/skills.js +117 -0
- package/dist/commands/types.d.ts +36 -0
- package/dist/commands/types.js +5 -0
- package/dist/components/InitWizard.js +61 -61
- package/dist/harness/config.d.ts +2 -0
- package/dist/harness/hooks.js +9 -6
- package/dist/harness/memory.js +28 -1
- package/dist/harness/plugins.d.ts +2 -0
- package/dist/harness/plugins.js +44 -11
- package/dist/harness/session-db.js +3 -1
- package/dist/harness/skill-registry.d.ts +21 -0
- package/dist/harness/skill-registry.js +35 -0
- package/dist/lsp/client.js +2 -1
- package/dist/main.js +10 -2
- package/dist/mcp/client.js +2 -1
- package/dist/mcp/server-mode.d.ts +10 -0
- package/dist/mcp/server-mode.js +17 -0
- package/dist/providers/anthropic.js +7 -8
- package/dist/providers/fallback.js +2 -3
- package/dist/providers/openai.js +3 -2
- package/dist/query/index.js +30 -6
- package/dist/query/tools.js +11 -0
- package/dist/query/types.d.ts +4 -0
- package/dist/renderer/layout-sections.d.ts +56 -0
- package/dist/renderer/layout-sections.js +462 -0
- package/dist/renderer/layout.d.ts +4 -2
- package/dist/renderer/layout.js +25 -500
- package/dist/repl.js +3 -1
- package/dist/services/SkillExtractor.js +2 -0
- package/dist/tools/SkillTool/index.js +26 -2
- package/dist/tools/TodoWriteTool/index.d.ts +37 -0
- package/dist/tools/TodoWriteTool/index.js +78 -0
- package/dist/tools.js +2 -0
- package/package.json +1 -1
package/dist/commands/index.js
CHANGED
|
@@ -3,1063 +3,33 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Commands are processed in the REPL before being sent to the LLM.
|
|
5
5
|
* If input starts with /, it's treated as a command.
|
|
6
|
+
*
|
|
7
|
+
* Command implementations are split into domain-specific modules:
|
|
8
|
+
* session.ts — /clear, /compact, /export, /history, /browse, /resume, /fork, /pin, /unpin
|
|
9
|
+
* git.ts — /diff, /undo, /rewind, /commit, /log
|
|
10
|
+
* info.ts — /help, /cost, /status, /config, /files, /model, /memory, /doctor, /context, /mcp, /init
|
|
11
|
+
* settings.ts — /theme, /companion, /fast, /keys, /effort, /sandbox, /permissions, /allowed-tools
|
|
12
|
+
* ai.ts — /plan, /review, /roles, /agents, /plugins, /btw, /loop, /cybergotchi
|
|
13
|
+
* skills.ts — /skill-create, /skill-delete, /skill-edit, /skill-search, /skill-install
|
|
6
14
|
*/
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
import { loadKeybindings } from "../harness/keybindings.js";
|
|
15
|
-
import { createSession, listSessions, loadSession, saveSession } from "../harness/session.js";
|
|
16
|
-
import { connectedMcpServers } from "../mcp/loader.js";
|
|
17
|
-
import { compressMessages } from "../query/index.js";
|
|
18
|
-
import { handleCybergotchiCommand } from "./cybergotchi.js";
|
|
15
|
+
import { registerAICommands } from "./ai.js";
|
|
16
|
+
import { registerGitCommands } from "./git.js";
|
|
17
|
+
import { registerInfoCommands } from "./info.js";
|
|
18
|
+
import { registerSessionCommands } from "./session.js";
|
|
19
|
+
import { registerSettingsCommands } from "./settings.js";
|
|
20
|
+
import { registerSkillCommands } from "./skills.js";
|
|
21
|
+
// ── Command Registry ──
|
|
19
22
|
const commands = new Map();
|
|
20
23
|
function register(name, description, handler) {
|
|
21
24
|
commands.set(name, { description, handler });
|
|
22
25
|
}
|
|
23
|
-
//
|
|
24
|
-
register
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"cost",
|
|
31
|
-
"status",
|
|
32
|
-
"config",
|
|
33
|
-
"files",
|
|
34
|
-
"model",
|
|
35
|
-
"memory",
|
|
36
|
-
"doctor",
|
|
37
|
-
"context",
|
|
38
|
-
"mcp",
|
|
39
|
-
"mcp-registry",
|
|
40
|
-
"init",
|
|
41
|
-
],
|
|
42
|
-
Settings: ["theme", "vim", "companion", "fast", "keys", "effort", "sandbox", "permissions", "allowed-tools"],
|
|
43
|
-
AI: ["plan", "review", "roles", "agents", "plugins", "btw", "loop"],
|
|
44
|
-
Pet: ["cybergotchi"],
|
|
45
|
-
};
|
|
46
|
-
const lines = [];
|
|
47
|
-
for (const [category, names] of Object.entries(categories)) {
|
|
48
|
-
lines.push(`${category}:`);
|
|
49
|
-
for (const name of names) {
|
|
50
|
-
const cmd = commands.get(name);
|
|
51
|
-
if (cmd)
|
|
52
|
-
lines.push(` /${name.padEnd(12)} ${cmd.description}`);
|
|
53
|
-
}
|
|
54
|
-
lines.push("");
|
|
55
|
-
}
|
|
56
|
-
// Include any uncategorized commands
|
|
57
|
-
const categorized = new Set(Object.values(categories).flat());
|
|
58
|
-
const uncategorized = [...commands.keys()].filter((n) => !categorized.has(n));
|
|
59
|
-
if (uncategorized.length > 0) {
|
|
60
|
-
lines.push("Other:");
|
|
61
|
-
for (const name of uncategorized) {
|
|
62
|
-
const cmd = commands.get(name);
|
|
63
|
-
lines.push(` /${name.padEnd(12)} ${cmd.description}`);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
return { output: lines.join("\n"), handled: true };
|
|
67
|
-
});
|
|
68
|
-
register("clear", "Clear conversation history", () => {
|
|
69
|
-
return { output: "Conversation cleared.", handled: true, clearMessages: true };
|
|
70
|
-
});
|
|
71
|
-
register("cost", "Show session cost and token usage", (_args, ctx) => {
|
|
72
|
-
const lines = [
|
|
73
|
-
`Cost: $${ctx.totalCost.toFixed(4)}`,
|
|
74
|
-
`Tokens: ${ctx.totalInputTokens.toLocaleString()} input, ${ctx.totalOutputTokens.toLocaleString()} output`,
|
|
75
|
-
`Model: ${ctx.model}`,
|
|
76
|
-
`Session: ${ctx.sessionId}`,
|
|
77
|
-
];
|
|
78
|
-
return { output: lines.join("\n"), handled: true };
|
|
79
|
-
});
|
|
80
|
-
register("status", "Show session status", (_args, ctx) => {
|
|
81
|
-
const lines = [
|
|
82
|
-
`Model: ${ctx.model}`,
|
|
83
|
-
`Mode: ${ctx.permissionMode}`,
|
|
84
|
-
`Messages: ${ctx.messages.length}`,
|
|
85
|
-
`Cost: $${ctx.totalCost.toFixed(4)}`,
|
|
86
|
-
`Session: ${ctx.sessionId}`,
|
|
87
|
-
];
|
|
88
|
-
if (isGitRepo()) {
|
|
89
|
-
lines.push(`Git branch: ${gitBranch()}`);
|
|
90
|
-
}
|
|
91
|
-
const mcp = connectedMcpServers();
|
|
92
|
-
if (mcp.length > 0) {
|
|
93
|
-
lines.push(`MCP servers: ${mcp.join(", ")}`);
|
|
94
|
-
}
|
|
95
|
-
return { output: lines.join("\n"), handled: true };
|
|
96
|
-
});
|
|
97
|
-
register("diff", "Show uncommitted git changes", () => {
|
|
98
|
-
if (!isGitRepo()) {
|
|
99
|
-
return { output: "Not a git repository.", handled: true };
|
|
100
|
-
}
|
|
101
|
-
const diff = gitDiff();
|
|
102
|
-
return { output: diff || "No uncommitted changes.", handled: true };
|
|
103
|
-
});
|
|
104
|
-
register("undo", "Undo last AI commit", () => {
|
|
105
|
-
if (!isGitRepo()) {
|
|
106
|
-
return { output: "Not a git repository.", handled: true };
|
|
107
|
-
}
|
|
108
|
-
const success = gitUndo();
|
|
109
|
-
return {
|
|
110
|
-
output: success ? "Undone. Last AI commit reverted." : "Nothing to undo (last commit wasn't from OpenHarness).",
|
|
111
|
-
handled: true,
|
|
112
|
-
};
|
|
113
|
-
});
|
|
114
|
-
register("rewind", "Restore files from checkpoint (interactive picker or last)", (args) => {
|
|
115
|
-
const { rewindLastCheckpoint, listCheckpoints, checkpointCount } = require("../harness/checkpoints.js");
|
|
116
|
-
const checkpoints = listCheckpoints();
|
|
117
|
-
if (checkpoints.length === 0) {
|
|
118
|
-
return { output: "No checkpoints available. Checkpoints are created before file modifications.", handled: true };
|
|
119
|
-
}
|
|
120
|
-
const idx = args.trim();
|
|
121
|
-
// /rewind (no args) — show checkpoint list
|
|
122
|
-
if (!idx) {
|
|
123
|
-
const lines = [`Checkpoints (${checkpoints.length}):\n`];
|
|
124
|
-
for (let i = checkpoints.length - 1; i >= 0; i--) {
|
|
125
|
-
const cp = checkpoints[i];
|
|
126
|
-
const age = Math.round((Date.now() - cp.timestamp) / 60_000);
|
|
127
|
-
lines.push(` ${i + 1}. [${age}m ago] ${cp.description}`);
|
|
128
|
-
lines.push(` Files: ${cp.files.join(", ")}`);
|
|
129
|
-
}
|
|
130
|
-
lines.push("");
|
|
131
|
-
lines.push("Usage: /rewind <number> to restore a specific checkpoint");
|
|
132
|
-
lines.push(" /rewind last to restore the most recent");
|
|
133
|
-
return { output: lines.join("\n"), handled: true };
|
|
134
|
-
}
|
|
135
|
-
// /rewind last — restore most recent
|
|
136
|
-
if (idx === "last") {
|
|
137
|
-
const cp = rewindLastCheckpoint();
|
|
138
|
-
if (!cp)
|
|
139
|
-
return { output: "No checkpoints.", handled: true };
|
|
140
|
-
return {
|
|
141
|
-
output: `Rewound: ${cp.description}\nRestored ${cp.files.length} file(s): ${cp.files.join(", ")}\n${checkpointCount()} checkpoint(s) remaining.`,
|
|
142
|
-
handled: true,
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
// /rewind <n> — restore specific checkpoint
|
|
146
|
-
const num = parseInt(idx, 10);
|
|
147
|
-
if (Number.isNaN(num) || num < 1 || num > checkpoints.length) {
|
|
148
|
-
return { output: `Invalid checkpoint number. Use 1-${checkpoints.length}.`, handled: true };
|
|
149
|
-
}
|
|
150
|
-
// Rewind to specific checkpoint (restore all from that point)
|
|
151
|
-
let restored = 0;
|
|
152
|
-
while (checkpointCount() >= num) {
|
|
153
|
-
const cp = rewindLastCheckpoint();
|
|
154
|
-
if (!cp)
|
|
155
|
-
break;
|
|
156
|
-
restored++;
|
|
157
|
-
if (checkpointCount() < num)
|
|
158
|
-
break;
|
|
159
|
-
}
|
|
160
|
-
return {
|
|
161
|
-
output: `Rewound ${restored} checkpoint(s) to point #${num}.\n${checkpointCount()} checkpoint(s) remaining.`,
|
|
162
|
-
handled: true,
|
|
163
|
-
};
|
|
164
|
-
});
|
|
165
|
-
register("commit", "Create a git commit", (args) => {
|
|
166
|
-
if (!isGitRepo()) {
|
|
167
|
-
return { output: "Not a git repository.", handled: true };
|
|
168
|
-
}
|
|
169
|
-
const message = args.trim() || "manual commit";
|
|
170
|
-
const success = gitCommit(message);
|
|
171
|
-
return { output: success ? `Committed: ${message}` : "Nothing to commit.", handled: true };
|
|
172
|
-
});
|
|
173
|
-
register("log", "Show recent git commits", () => {
|
|
174
|
-
if (!isGitRepo()) {
|
|
175
|
-
return { output: "Not a git repository.", handled: true };
|
|
176
|
-
}
|
|
177
|
-
return { output: gitLog(10) || "No commits yet.", handled: true };
|
|
178
|
-
});
|
|
179
|
-
register("history", "List recent sessions or search across them", (args) => {
|
|
180
|
-
const parts = args.trim().split(/\s+/);
|
|
181
|
-
const sessionDir = join(homedir(), ".oh", "sessions");
|
|
182
|
-
if (parts[0] === "search" && parts[1]) {
|
|
183
|
-
const term = parts.slice(1).join(" ").toLowerCase();
|
|
184
|
-
const sessions = listSessions(sessionDir);
|
|
185
|
-
const matches = [];
|
|
186
|
-
for (const s of sessions) {
|
|
187
|
-
try {
|
|
188
|
-
const full = loadSession(s.id, sessionDir);
|
|
189
|
-
const hit = full.messages.find((m) => typeof m.content === "string" && m.content.toLowerCase().includes(term));
|
|
190
|
-
if (hit) {
|
|
191
|
-
const date = new Date(s.updatedAt).toLocaleDateString();
|
|
192
|
-
matches.push(` ${s.id} ${date} ${s.model || "?"}`);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
catch {
|
|
196
|
-
/* skip */
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
if (matches.length === 0)
|
|
200
|
-
return { output: `No sessions matching "${term}".`, handled: true };
|
|
201
|
-
return { output: `Sessions matching "${term}":\n${matches.join("\n")}`, handled: true };
|
|
202
|
-
}
|
|
203
|
-
const n = parseInt(parts[0] ?? "10", 10) || 10;
|
|
204
|
-
const sessions = listSessions(sessionDir).slice(0, n);
|
|
205
|
-
if (sessions.length === 0)
|
|
206
|
-
return { output: "No saved sessions.", handled: true };
|
|
207
|
-
const lines = sessions.map((s) => {
|
|
208
|
-
const date = new Date(s.updatedAt).toLocaleDateString();
|
|
209
|
-
const cost = s.cost > 0 ? ` $${s.cost.toFixed(4)}` : "";
|
|
210
|
-
return ` ${s.id} ${date} ${String(s.messages).padStart(3)} msgs ${(s.model || "?").slice(0, 24)}${cost}`;
|
|
211
|
-
});
|
|
212
|
-
return { output: `Recent sessions (use /resume <id> to continue):\n${lines.join("\n")}`, handled: true };
|
|
213
|
-
});
|
|
214
|
-
register("theme", "Switch theme (dark/light)", (args) => {
|
|
215
|
-
const theme = args.trim().toLowerCase();
|
|
216
|
-
if (theme !== "dark" && theme !== "light") {
|
|
217
|
-
return { output: "Usage: /theme dark or /theme light", handled: true };
|
|
218
|
-
}
|
|
219
|
-
return { output: `__SWITCH_THEME__:${theme}`, handled: true };
|
|
220
|
-
});
|
|
221
|
-
register("browse", "Open interactive session browser", () => {
|
|
222
|
-
return { output: "__OPEN_SESSION_BROWSER__", handled: true };
|
|
223
|
-
});
|
|
224
|
-
register("resume", "Resume a saved session by ID", (args) => {
|
|
225
|
-
const id = args.trim();
|
|
226
|
-
if (!id)
|
|
227
|
-
return { output: "Usage: /resume <session-id>", handled: true };
|
|
228
|
-
const sessionDir = join(homedir(), ".oh", "sessions");
|
|
229
|
-
try {
|
|
230
|
-
loadSession(id, sessionDir); // validate it exists
|
|
231
|
-
return { output: `Resuming session ${id}...`, handled: true, resumeSessionId: id };
|
|
232
|
-
}
|
|
233
|
-
catch {
|
|
234
|
-
return { output: `Session not found: ${id}`, handled: true };
|
|
235
|
-
}
|
|
236
|
-
});
|
|
237
|
-
register("fork", "Fork current session (create a branch you can resume later)", (_args, ctx) => {
|
|
238
|
-
const forked = createSession("", "");
|
|
239
|
-
forked.messages = [...ctx.messages];
|
|
240
|
-
saveSession(forked);
|
|
241
|
-
return {
|
|
242
|
-
output: `Session forked as ${forked.id}. Resume later with: oh --resume ${forked.id}`,
|
|
243
|
-
handled: true,
|
|
244
|
-
};
|
|
245
|
-
});
|
|
246
|
-
register("files", "List files in context", (_args, ctx) => {
|
|
247
|
-
const files = new Set();
|
|
248
|
-
for (const msg of ctx.messages) {
|
|
249
|
-
// Extract file paths from tool calls
|
|
250
|
-
if (msg.toolCalls) {
|
|
251
|
-
for (const tc of msg.toolCalls) {
|
|
252
|
-
const path = tc.arguments?.file_path ?? tc.arguments?.path;
|
|
253
|
-
if (path)
|
|
254
|
-
files.add(String(path));
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
if (files.size === 0)
|
|
259
|
-
return { output: "No files in context yet.", handled: true };
|
|
260
|
-
return { output: `Files in context:\n${[...files].map((f) => ` ${f}`).join("\n")}`, handled: true };
|
|
261
|
-
});
|
|
262
|
-
register("model", "Switch model (e.g., /model llama3.2 or /model ollama/llama3.2)", (args, ctx) => {
|
|
263
|
-
const model = args.trim();
|
|
264
|
-
if (!model)
|
|
265
|
-
return { output: "Usage: /model <model-name> (prefix with provider/ to switch providers)", handled: true };
|
|
266
|
-
// Detect the provider implied by the new model
|
|
267
|
-
let newProviderName;
|
|
268
|
-
if (model.includes("/")) {
|
|
269
|
-
newProviderName = model.split("/")[0];
|
|
270
|
-
}
|
|
271
|
-
else {
|
|
272
|
-
// No prefix — assume current session's provider (don't guess)
|
|
273
|
-
newProviderName = ctx.providerName;
|
|
274
|
-
}
|
|
275
|
-
if (newProviderName !== ctx.providerName) {
|
|
276
|
-
return {
|
|
277
|
-
output: `Cannot switch to '${model}': requires the '${newProviderName}' provider but current session uses '${ctx.providerName}'.\nRestart with: oh --model ${newProviderName}/${model.includes("/") ? model.split("/").slice(1).join("/") : model}`,
|
|
278
|
-
handled: true,
|
|
279
|
-
};
|
|
280
|
-
}
|
|
281
|
-
// Strip provider prefix if present (provider is already correct)
|
|
282
|
-
const modelName = model.includes("/") ? model.split("/").slice(1).join("/") : model;
|
|
283
|
-
return { output: `Switched to ${modelName}.`, handled: true, newModel: modelName };
|
|
284
|
-
});
|
|
285
|
-
register("compact", "Compress conversation history (optional: focus keyword or message number)", (args, ctx) => {
|
|
286
|
-
const focus = args.trim();
|
|
287
|
-
const before = ctx.messages.length;
|
|
288
|
-
const targetTokens = Math.floor(getContextWindow(ctx.model) * 0.6);
|
|
289
|
-
if (focus && /^\d+$/.test(focus)) {
|
|
290
|
-
// Numeric: compact messages 1-N, keep N+1 onwards
|
|
291
|
-
const cutoff = parseInt(focus, 10);
|
|
292
|
-
if (cutoff < 1 || cutoff >= before) {
|
|
293
|
-
return { output: `Invalid: use 1-${before - 1}`, handled: true };
|
|
294
|
-
}
|
|
295
|
-
const kept = ctx.messages.slice(cutoff);
|
|
296
|
-
return {
|
|
297
|
-
output: `Compacted: removed first ${cutoff} messages, kept ${kept.length}.`,
|
|
298
|
-
handled: true,
|
|
299
|
-
compactedMessages: kept,
|
|
300
|
-
};
|
|
301
|
-
}
|
|
302
|
-
if (focus) {
|
|
303
|
-
// Keyword focus: compress but preserve messages containing the keyword
|
|
304
|
-
const focusLower = focus.toLowerCase();
|
|
305
|
-
const preserved = ctx.messages.filter((m) => m.content.toLowerCase().includes(focusLower) || m.meta?.pinned);
|
|
306
|
-
const others = ctx.messages.filter((m) => !m.content.toLowerCase().includes(focusLower) && !m.meta?.pinned);
|
|
307
|
-
const compactedOthers = compressMessages(others, targetTokens);
|
|
308
|
-
const merged = [...compactedOthers, ...preserved].sort((a, b) => a.timestamp - b.timestamp);
|
|
309
|
-
return {
|
|
310
|
-
output: `Compacted with focus "${focus}": ${before} → ${merged.length} messages (preserved ${preserved.length} matching).`,
|
|
311
|
-
handled: true,
|
|
312
|
-
compactedMessages: merged,
|
|
313
|
-
};
|
|
314
|
-
}
|
|
315
|
-
// Default: compress everything
|
|
316
|
-
const compacted = compressMessages(ctx.messages, targetTokens);
|
|
317
|
-
const dropped = before - compacted.length;
|
|
318
|
-
return {
|
|
319
|
-
output: `Compacted: ${before} → ${compacted.length} messages (dropped ${dropped} older turns).`,
|
|
320
|
-
handled: true,
|
|
321
|
-
compactedMessages: compacted,
|
|
322
|
-
};
|
|
323
|
-
});
|
|
324
|
-
register("export", "Export conversation to file", (_args, ctx) => {
|
|
325
|
-
const lines = ctx.messages
|
|
326
|
-
.filter((m) => m.role === "user" || m.role === "assistant")
|
|
327
|
-
.map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.content}`)
|
|
328
|
-
.join("\n\n");
|
|
329
|
-
const filename = `.oh/export-${ctx.sessionId}.md`;
|
|
330
|
-
try {
|
|
331
|
-
mkdirSync(dirname(filename), { recursive: true });
|
|
332
|
-
writeFileSync(filename, lines);
|
|
333
|
-
return { output: `Exported to ${filename}`, handled: true };
|
|
334
|
-
}
|
|
335
|
-
catch {
|
|
336
|
-
return { output: `Export failed. Content:\n\n${lines.slice(0, 500)}`, handled: true };
|
|
337
|
-
}
|
|
338
|
-
});
|
|
339
|
-
register("config", "Show current configuration", (_args, ctx) => {
|
|
340
|
-
const saved = readOhConfig();
|
|
341
|
-
const lines = ["Configuration:"];
|
|
342
|
-
if (saved) {
|
|
343
|
-
lines.push(` Provider: ${saved.provider}`);
|
|
344
|
-
lines.push(` Model: ${saved.model}`);
|
|
345
|
-
lines.push(` Permission: ${saved.permissionMode}`);
|
|
346
|
-
if (saved.baseUrl)
|
|
347
|
-
lines.push(` Base URL: ${saved.baseUrl}`);
|
|
348
|
-
if (saved.apiKey)
|
|
349
|
-
lines.push(` API key: ${"*".repeat(8)}...`);
|
|
350
|
-
lines.push(` Source: .oh/config.yaml`);
|
|
351
|
-
}
|
|
352
|
-
else {
|
|
353
|
-
lines.push(` No .oh/config.yaml found — run oh init to create one`);
|
|
354
|
-
}
|
|
355
|
-
lines.push("");
|
|
356
|
-
lines.push(` Active model: ${ctx.model}`);
|
|
357
|
-
lines.push(` Permission mode: ${ctx.permissionMode}`);
|
|
358
|
-
const mcp = connectedMcpServers();
|
|
359
|
-
if (mcp.length > 0)
|
|
360
|
-
lines.push(` MCP servers: ${mcp.join(", ")}`);
|
|
361
|
-
return { output: lines.join("\n"), handled: true };
|
|
362
|
-
});
|
|
363
|
-
register("memory", "View and search memories in .oh/memory/", (args) => {
|
|
364
|
-
const memDir = join(process.cwd(), ".oh", "memory");
|
|
365
|
-
if (!existsSync(memDir)) {
|
|
366
|
-
return { output: "No .oh/memory/ directory found. Memories are stored there by the AI.", handled: true };
|
|
367
|
-
}
|
|
368
|
-
const term = args.trim().toLowerCase();
|
|
369
|
-
let files;
|
|
370
|
-
try {
|
|
371
|
-
files = readdirSync(memDir).filter((f) => f.endsWith(".md"));
|
|
372
|
-
}
|
|
373
|
-
catch {
|
|
374
|
-
return { output: "Could not read .oh/memory/", handled: true };
|
|
375
|
-
}
|
|
376
|
-
if (files.length === 0)
|
|
377
|
-
return { output: "No memories stored yet.", handled: true };
|
|
378
|
-
if (term) {
|
|
379
|
-
// Search mode
|
|
380
|
-
const matches = [];
|
|
381
|
-
for (const file of files) {
|
|
382
|
-
try {
|
|
383
|
-
const content = readFileSync(join(memDir, file), "utf-8");
|
|
384
|
-
if (content.toLowerCase().includes(term)) {
|
|
385
|
-
const firstLine = content.split("\n").find((l) => l.trim() && !l.startsWith("---")) ?? file;
|
|
386
|
-
matches.push(` ${file.padEnd(30)} ${firstLine.slice(0, 50)}`);
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
catch {
|
|
390
|
-
/* skip */
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
if (matches.length === 0)
|
|
394
|
-
return { output: `No memories matching "${term}".`, handled: true };
|
|
395
|
-
return { output: `Memories matching "${term}":\n${matches.join("\n")}`, handled: true };
|
|
396
|
-
}
|
|
397
|
-
// List mode
|
|
398
|
-
const lines = [`Memories (${files.length}) — use /memory <term> to search:\n`];
|
|
399
|
-
for (const file of files) {
|
|
400
|
-
try {
|
|
401
|
-
const content = readFileSync(join(memDir, file), "utf-8");
|
|
402
|
-
const nameLine = content.match(/^name:\s*(.+)$/m)?.[1] ?? file.replace(".md", "");
|
|
403
|
-
const typeLine = content.match(/^type:\s*(.+)$/m)?.[1] ?? "?";
|
|
404
|
-
const descLine = content.match(/^description:\s*(.+)$/m)?.[1] ?? "";
|
|
405
|
-
lines.push(` [${typeLine.padEnd(8)}] ${nameLine.padEnd(24)} ${descLine.slice(0, 40)}`);
|
|
406
|
-
}
|
|
407
|
-
catch {
|
|
408
|
-
lines.push(` ${file}`);
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
return { output: lines.join("\n"), handled: true };
|
|
412
|
-
});
|
|
413
|
-
register("companion", "Toggle companion visibility (off/on)", (args) => {
|
|
414
|
-
const arg = args.trim().toLowerCase();
|
|
415
|
-
if (arg === "off")
|
|
416
|
-
return { output: "__COMPANION_OFF__", handled: true };
|
|
417
|
-
if (arg === "on")
|
|
418
|
-
return { output: "__COMPANION_ON__", handled: true };
|
|
419
|
-
return { output: "Usage: /companion off or /companion on", handled: true };
|
|
420
|
-
});
|
|
421
|
-
register("cybergotchi", "Manage your cybergotchi — feed · pet · rest · status · rename · reset", (args) => {
|
|
422
|
-
return handleCybergotchiCommand(args);
|
|
423
|
-
});
|
|
424
|
-
register("roles", "List available agent specialization roles", () => {
|
|
425
|
-
const { listRoles } = require("../agents/roles.js");
|
|
426
|
-
const roles = listRoles();
|
|
427
|
-
const lines = ["Available agent roles:\n"];
|
|
428
|
-
for (const role of roles) {
|
|
429
|
-
lines.push(` ${role.id.padEnd(18)} ${role.name}`);
|
|
430
|
-
lines.push(` ${"".padEnd(18)} ${role.description}`);
|
|
431
|
-
if (role.suggestedTools?.length) {
|
|
432
|
-
lines.push(` ${"".padEnd(18)} Tools: ${role.suggestedTools.join(", ")}`);
|
|
433
|
-
}
|
|
434
|
-
lines.push("");
|
|
435
|
-
}
|
|
436
|
-
lines.push("Usage: Agent({ subagent_type: 'code-reviewer', prompt: '...' })");
|
|
437
|
-
return { output: lines.join("\n"), handled: true };
|
|
438
|
-
});
|
|
439
|
-
register("agents", "Discover running openHarness agents on this machine", () => {
|
|
440
|
-
const { discoverAgents } = require("../services/a2a.js");
|
|
441
|
-
const agents = discoverAgents();
|
|
442
|
-
if (agents.length === 0) {
|
|
443
|
-
return {
|
|
444
|
-
output: "No other openHarness agents running on this machine.\n\nOther oh sessions will appear here automatically via the A2A protocol.",
|
|
445
|
-
handled: true,
|
|
446
|
-
};
|
|
447
|
-
}
|
|
448
|
-
const lines = [`Running Agents (${agents.length}):\n`];
|
|
449
|
-
for (const agent of agents) {
|
|
450
|
-
const age = Math.round((Date.now() - agent.registeredAt) / 60_000);
|
|
451
|
-
lines.push(` ${agent.name}`);
|
|
452
|
-
lines.push(` ID: ${agent.id}`);
|
|
453
|
-
lines.push(` Provider: ${agent.provider ?? "unknown"} / ${agent.model ?? "unknown"}`);
|
|
454
|
-
lines.push(` Dir: ${agent.workingDir ?? "unknown"}`);
|
|
455
|
-
lines.push(` Endpoint: ${agent.endpoint.type}${agent.endpoint.port ? `:${agent.endpoint.port}` : ""}`);
|
|
456
|
-
lines.push(` Uptime: ${age}m`);
|
|
457
|
-
lines.push(` Caps: ${agent.capabilities.map((c) => c.name).join(", ")}`);
|
|
458
|
-
lines.push("");
|
|
459
|
-
}
|
|
460
|
-
lines.push("Send messages with: Agent({ prompt: 'ask the other agent...', allowed_tools: ['SendMessage'] })");
|
|
461
|
-
return { output: lines.join("\n"), handled: true };
|
|
462
|
-
});
|
|
463
|
-
register("fast", "Toggle fast mode (optimized for speed)", () => {
|
|
464
|
-
return { output: "", handled: true, toggleFastMode: true };
|
|
465
|
-
});
|
|
466
|
-
register("keys", "Show keyboard shortcuts", () => {
|
|
467
|
-
const bindings = loadKeybindings();
|
|
468
|
-
const shortcuts = [
|
|
469
|
-
"Keyboard Shortcuts:",
|
|
470
|
-
"",
|
|
471
|
-
" Navigation:",
|
|
472
|
-
" ↑ / ↓ Input history",
|
|
473
|
-
" Tab Cycle autocomplete suggestions",
|
|
474
|
-
" Escape Cancel / clear autocomplete",
|
|
475
|
-
" Ctrl+C Abort current request / exit",
|
|
476
|
-
" Scroll wheel Scroll through messages",
|
|
477
|
-
"",
|
|
478
|
-
" Editing:",
|
|
479
|
-
" Alt+Enter Insert newline (multi-line input)",
|
|
480
|
-
" Ctrl+A Move cursor to start of line",
|
|
481
|
-
" Ctrl+E Move cursor to end of line",
|
|
482
|
-
"",
|
|
483
|
-
" Display:",
|
|
484
|
-
" Ctrl+K Toggle code block expansion",
|
|
485
|
-
" Ctrl+O Toggle thinking block expansion",
|
|
486
|
-
" Tab (in output) Expand/collapse tool call output",
|
|
487
|
-
"",
|
|
488
|
-
" Custom keybindings (~/.oh/keybindings.json):",
|
|
489
|
-
];
|
|
490
|
-
for (const b of bindings) {
|
|
491
|
-
shortcuts.push(` ${b.key.padEnd(18)} ${b.action}`);
|
|
492
|
-
}
|
|
493
|
-
shortcuts.push("", " Session:", " /vim Toggle Vim mode", " /browse Interactive session browser", " /theme dark|light Switch theme");
|
|
494
|
-
return { output: shortcuts.join("\n"), handled: true };
|
|
495
|
-
});
|
|
496
|
-
register("sandbox", "Show sandbox status and restrictions", () => {
|
|
497
|
-
const { sandboxStatus } = require("../harness/sandbox.js");
|
|
498
|
-
return { output: `${sandboxStatus()}\n\nConfigure in .oh/config.yaml under sandbox:`, handled: true };
|
|
499
|
-
});
|
|
500
|
-
register("effort", "Set reasoning effort level (low/medium/high/max)", (args) => {
|
|
501
|
-
const level = args.trim().toLowerCase();
|
|
502
|
-
const valid = ["low", "medium", "high", "max"];
|
|
503
|
-
if (!valid.includes(level)) {
|
|
504
|
-
return {
|
|
505
|
-
output: `Usage: /effort <${valid.join("|")}>\n\nlow — fast, minimal reasoning\nmedium — balanced (default)\nhigh — thorough reasoning\nmax — maximum depth (Opus only)`,
|
|
506
|
-
handled: true,
|
|
507
|
-
};
|
|
508
|
-
}
|
|
509
|
-
return { output: `Effort level set to: ${level}`, handled: true };
|
|
510
|
-
});
|
|
511
|
-
register("btw", "Ask a side question (ephemeral, no tools, not saved to history)", (args) => {
|
|
512
|
-
if (!args.trim()) {
|
|
513
|
-
return { output: "Usage: /btw <your question>", handled: true };
|
|
514
|
-
}
|
|
515
|
-
// Side questions are answered directly without tools or history
|
|
516
|
-
// The output is shown but NOT added to conversation history
|
|
517
|
-
return {
|
|
518
|
-
output: `[btw] ${args.trim()}`,
|
|
519
|
-
handled: false,
|
|
520
|
-
prependToPrompt: `[Side question — answer briefly without using any tools. This is ephemeral and not part of the main conversation.]\n\n${args.trim()}`,
|
|
521
|
-
};
|
|
522
|
-
});
|
|
523
|
-
register("loop", "Run a prompt repeatedly with self-paced timing", (args) => {
|
|
524
|
-
const input = args.trim();
|
|
525
|
-
if (!input) {
|
|
526
|
-
return {
|
|
527
|
-
output: "Usage: /loop [interval] <prompt or /command>\n\nExamples:\n /loop check if the build passed\n /loop 5m /review\n\nOmit the interval to let the model self-pace via ScheduleWakeup.",
|
|
528
|
-
handled: true,
|
|
529
|
-
};
|
|
530
|
-
}
|
|
531
|
-
// Check for optional interval prefix like "5m", "30s", "2h"
|
|
532
|
-
const intervalMatch = input.match(/^(\d+)(s|m|h)\s+(.+)$/);
|
|
533
|
-
let intervalMs = null;
|
|
534
|
-
let prompt;
|
|
535
|
-
if (intervalMatch) {
|
|
536
|
-
const [, num, unit, rest] = intervalMatch;
|
|
537
|
-
const multipliers = { s: 1000, m: 60000, h: 3600000 };
|
|
538
|
-
intervalMs = parseInt(num, 10) * multipliers[unit];
|
|
539
|
-
prompt = rest;
|
|
540
|
-
}
|
|
541
|
-
else {
|
|
542
|
-
prompt = input;
|
|
543
|
-
}
|
|
544
|
-
const mode = intervalMs
|
|
545
|
-
? `Fixed interval: ${intervalMatch[1]}${intervalMatch[2]}`
|
|
546
|
-
: "Dynamic (model self-paces via ScheduleWakeup)";
|
|
547
|
-
return {
|
|
548
|
-
output: `[loop] ${mode}\nPrompt: ${prompt}`,
|
|
549
|
-
handled: false,
|
|
550
|
-
prependToPrompt: intervalMs
|
|
551
|
-
? `You are in LOOP MODE (fixed interval: ${intervalMs / 1000}s). Execute this task, then use ScheduleWakeup with delaySeconds=${intervalMs / 1000} to schedule the next iteration.\n\nTask: ${prompt}`
|
|
552
|
-
: `You are in LOOP MODE (dynamic pacing). Execute this task, then use ScheduleWakeup to schedule the next iteration at an appropriate interval. Choose your delay based on what you're waiting for. Omit the ScheduleWakeup call to end the loop.\n\nTask: ${prompt}`,
|
|
553
|
-
};
|
|
554
|
-
});
|
|
555
|
-
register("plan", "Enter plan mode", (_args, _ctx) => {
|
|
556
|
-
const task = _args.trim();
|
|
557
|
-
if (!task) {
|
|
558
|
-
return { output: "Usage: /plan <what you want to build>", handled: true };
|
|
559
|
-
}
|
|
560
|
-
return {
|
|
561
|
-
output: `[plan mode] ${task}`,
|
|
562
|
-
handled: false,
|
|
563
|
-
prependToPrompt: `You are in PLAN MODE. Do NOT write any code yet.\n\n1. Call EnterPlanMode to create a plan file in .oh/plans/\n2. Write your detailed implementation plan to that file (files to create/modify, key functions/types, data flow, edge cases)\n3. When the plan is complete, call ExitPlanMode to signal readiness for review\n\nTask: ${task}`,
|
|
564
|
-
};
|
|
565
|
-
});
|
|
566
|
-
register("review", "Review recent code changes", () => {
|
|
567
|
-
if (!isGitRepo()) {
|
|
568
|
-
return { output: "Not a git repository.", handled: true };
|
|
569
|
-
}
|
|
570
|
-
const diff = gitDiff();
|
|
571
|
-
if (!diff)
|
|
572
|
-
return { output: "No changes to review.", handled: true };
|
|
573
|
-
const lines = diff.split("\n").length;
|
|
574
|
-
return {
|
|
575
|
-
output: `[review] ${lines} lines of diff`,
|
|
576
|
-
handled: false,
|
|
577
|
-
prependToPrompt: `Review these uncommitted changes and give feedback on correctness, style, and potential issues:\n\n\`\`\`diff\n${diff}\n\`\`\`\n\n`,
|
|
578
|
-
};
|
|
579
|
-
});
|
|
580
|
-
register("doctor", "Run diagnostic health checks", (_args, ctx) => {
|
|
581
|
-
const lines = [];
|
|
582
|
-
const issues = [];
|
|
583
|
-
lines.push("─── Health Check ───");
|
|
584
|
-
lines.push("");
|
|
585
|
-
// Provider & Model
|
|
586
|
-
lines.push(` Provider: ${ctx.providerName || "⚠ not set"}`);
|
|
587
|
-
lines.push(` Model: ${ctx.model || "⚠ not set"}`);
|
|
588
|
-
lines.push(` Permission: ${ctx.permissionMode}`);
|
|
589
|
-
if (!ctx.model)
|
|
590
|
-
issues.push("No model configured. Use --model or set in .oh/config.yaml");
|
|
591
|
-
// API Key check
|
|
592
|
-
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
|
|
593
|
-
const hasOpenAIKey = !!process.env.OPENAI_API_KEY;
|
|
594
|
-
if (ctx.providerName === "anthropic" && !hasAnthropicKey) {
|
|
595
|
-
issues.push("ANTHROPIC_API_KEY not set. Run: export ANTHROPIC_API_KEY=sk-...");
|
|
596
|
-
}
|
|
597
|
-
if (ctx.providerName === "openai" && !hasOpenAIKey) {
|
|
598
|
-
issues.push("OPENAI_API_KEY not set. Run: export OPENAI_API_KEY=sk-...");
|
|
599
|
-
}
|
|
600
|
-
if (ctx.providerName === "ollama") {
|
|
601
|
-
lines.push(` Ollama: checking...`);
|
|
602
|
-
try {
|
|
603
|
-
// Quick check if Ollama is running (sync fetch isn't available, just note it)
|
|
604
|
-
lines.pop();
|
|
605
|
-
lines.push(` Ollama: configured (ensure 'ollama serve' is running)`);
|
|
606
|
-
}
|
|
607
|
-
catch {
|
|
608
|
-
issues.push("Ollama may not be running. Start with: ollama serve");
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
// Context window
|
|
612
|
-
const ctxWindow = getContextWindow(ctx.model);
|
|
613
|
-
const totalTokens = estimateMessageTokens(ctx.messages);
|
|
614
|
-
const usage = ctxWindow > 0 ? Math.round((totalTokens / ctxWindow) * 100) : 0;
|
|
615
|
-
lines.push(` Context: ~${totalTokens.toLocaleString()} / ${ctxWindow.toLocaleString()} tokens (${usage}%)`);
|
|
616
|
-
if (usage > 80)
|
|
617
|
-
issues.push(`Context ${usage}% full. Consider /compact to free space.`);
|
|
618
|
-
// Git
|
|
619
|
-
lines.push("");
|
|
620
|
-
if (isGitRepo()) {
|
|
621
|
-
lines.push(` Git: ✓ (branch: ${gitBranch()})`);
|
|
622
|
-
if (isInMergeOrRebase()) {
|
|
623
|
-
lines.push(` ⚠ Merge/rebase in progress`);
|
|
624
|
-
issues.push("Git merge/rebase in progress. Resolve before making changes.");
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
else {
|
|
628
|
-
lines.push(` Git: ✗ (not a git repo)`);
|
|
629
|
-
}
|
|
630
|
-
// MCP
|
|
631
|
-
const mcp = connectedMcpServers();
|
|
632
|
-
lines.push(` MCP servers: ${mcp.length > 0 ? mcp.join(", ") : "none"}`);
|
|
633
|
-
// Config
|
|
634
|
-
const cfg = readOhConfig();
|
|
635
|
-
lines.push(` Config: ${cfg ? ".oh/config.yaml ✓" : "not found"}`);
|
|
636
|
-
// Session
|
|
637
|
-
lines.push("");
|
|
638
|
-
lines.push(` Session: ${ctx.sessionId}`);
|
|
639
|
-
lines.push(` Messages: ${ctx.messages.length}`);
|
|
640
|
-
lines.push(` Cost: $${ctx.totalCost.toFixed(4)}`);
|
|
641
|
-
// Disk space & storage
|
|
642
|
-
try {
|
|
643
|
-
const ohDir = join(homedir(), ".oh");
|
|
644
|
-
if (existsSync(ohDir)) {
|
|
645
|
-
const sessionsDir = join(ohDir, "sessions");
|
|
646
|
-
const sessCount = existsSync(sessionsDir)
|
|
647
|
-
? readdirSync(sessionsDir).filter((f) => f.endsWith(".json")).length
|
|
648
|
-
: 0;
|
|
649
|
-
lines.push(` Sessions: ${sessCount} saved`);
|
|
650
|
-
if (sessCount > 80)
|
|
651
|
-
issues.push(`${sessCount} saved sessions. Consider cleaning old ones.`);
|
|
652
|
-
// Memory stats
|
|
653
|
-
const memDir = join(ohDir, "memory");
|
|
654
|
-
const memCount = existsSync(memDir) ? readdirSync(memDir).filter((f) => f.endsWith(".md")).length : 0;
|
|
655
|
-
lines.push(` Memories: ${memCount} global`);
|
|
656
|
-
// Cron stats
|
|
657
|
-
const cronDir = join(ohDir, "crons");
|
|
658
|
-
const cronCount = existsSync(cronDir) ? readdirSync(cronDir).filter((f) => f.endsWith(".json")).length : 0;
|
|
659
|
-
lines.push(` Cron tasks: ${cronCount}`);
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
catch {
|
|
663
|
-
/* ignore */
|
|
664
|
-
}
|
|
665
|
-
// Project-level stats
|
|
666
|
-
try {
|
|
667
|
-
const projMemDir = join(".oh", "memory");
|
|
668
|
-
const projMemCount = existsSync(projMemDir) ? readdirSync(projMemDir).filter((f) => f.endsWith(".md")).length : 0;
|
|
669
|
-
if (projMemCount > 0)
|
|
670
|
-
lines.push(` Project mems: ${projMemCount}`);
|
|
671
|
-
const skillsDir = join(".oh", "skills");
|
|
672
|
-
const skillCount = existsSync(skillsDir) ? readdirSync(skillsDir).filter((f) => f.endsWith(".md")).length : 0;
|
|
673
|
-
if (skillCount > 0)
|
|
674
|
-
lines.push(` Skills: ${skillCount}`);
|
|
675
|
-
}
|
|
676
|
-
catch {
|
|
677
|
-
/* ignore */
|
|
678
|
-
}
|
|
679
|
-
// Global config
|
|
680
|
-
const globalCfg = existsSync(join(homedir(), ".oh", "config.yaml"));
|
|
681
|
-
lines.push(` Global config: ${globalCfg ? "~/.oh/config.yaml ✓" : "not set (optional)"}`);
|
|
682
|
-
// Verification config
|
|
683
|
-
try {
|
|
684
|
-
const { getVerificationConfig } = require("../harness/verification.js");
|
|
685
|
-
const vCfg = getVerificationConfig();
|
|
686
|
-
if (vCfg?.enabled) {
|
|
687
|
-
lines.push(` Verification: ✓ (${vCfg.rules.length} rules, mode: ${vCfg.mode})`);
|
|
688
|
-
}
|
|
689
|
-
else {
|
|
690
|
-
lines.push(` Verification: off (no rules detected)`);
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
catch {
|
|
694
|
-
/* ignore */
|
|
695
|
-
}
|
|
696
|
-
// Tools
|
|
697
|
-
lines.push("");
|
|
698
|
-
lines.push(` Tools: ${ctx.messages.length > 0 ? "ready" : "loaded"}`);
|
|
699
|
-
// Node.js version
|
|
700
|
-
lines.push(` Node.js: ${process.version}`);
|
|
701
|
-
const [major] = process.version.slice(1).split(".").map(Number);
|
|
702
|
-
if (major && major < 18)
|
|
703
|
-
issues.push(`Node.js ${process.version} is below minimum (18+). Upgrade Node.js.`);
|
|
704
|
-
// Issues summary
|
|
705
|
-
if (issues.length > 0) {
|
|
706
|
-
lines.push("");
|
|
707
|
-
lines.push("─── Issues Found ───");
|
|
708
|
-
for (const issue of issues) {
|
|
709
|
-
lines.push(` ⚠ ${issue}`);
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
else {
|
|
713
|
-
lines.push("");
|
|
714
|
-
lines.push(" ✓ No issues found");
|
|
715
|
-
}
|
|
716
|
-
return { output: lines.join("\n"), handled: true };
|
|
717
|
-
});
|
|
718
|
-
register("context", "Show context window usage breakdown", (_args, ctx) => {
|
|
719
|
-
const ctxWindow = getContextWindow(ctx.model);
|
|
720
|
-
// Categorize messages by type
|
|
721
|
-
let userTokens = 0, assistantTokens = 0, toolTokens = 0, systemTokens = 0;
|
|
722
|
-
for (const msg of ctx.messages) {
|
|
723
|
-
const tokens = Math.ceil((msg.content?.length ?? 0) / 4);
|
|
724
|
-
switch (msg.role) {
|
|
725
|
-
case "user":
|
|
726
|
-
userTokens += tokens;
|
|
727
|
-
break;
|
|
728
|
-
case "assistant":
|
|
729
|
-
assistantTokens += tokens;
|
|
730
|
-
break;
|
|
731
|
-
case "tool":
|
|
732
|
-
toolTokens += tokens;
|
|
733
|
-
break;
|
|
734
|
-
case "system":
|
|
735
|
-
systemTokens += tokens;
|
|
736
|
-
break;
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
const totalTokens = userTokens + assistantTokens + toolTokens + systemTokens;
|
|
740
|
-
const freeTokens = ctxWindow - totalTokens;
|
|
741
|
-
const usage = totalTokens / ctxWindow;
|
|
742
|
-
// Visual bar (30 chars wide)
|
|
743
|
-
const barWidth = 30;
|
|
744
|
-
const filled = Math.round(usage * barWidth);
|
|
745
|
-
const bar = "\u2588".repeat(filled) + "\u2591".repeat(barWidth - filled);
|
|
746
|
-
const pct = (n) => `${((n / ctxWindow) * 100).toFixed(1)}%`;
|
|
747
|
-
const pad = (s, n) => s.padEnd(n);
|
|
748
|
-
const lines = [
|
|
749
|
-
`Context Window (${ctxWindow.toLocaleString()} tokens):`,
|
|
750
|
-
"",
|
|
751
|
-
` ${pad("User messages:", 20)} ${userTokens.toLocaleString().padStart(8)} tokens (${pct(userTokens)})`,
|
|
752
|
-
` ${pad("Assistant:", 20)} ${assistantTokens.toLocaleString().padStart(8)} tokens (${pct(assistantTokens)})`,
|
|
753
|
-
` ${pad("Tool results:", 20)} ${toolTokens.toLocaleString().padStart(8)} tokens (${pct(toolTokens)})`,
|
|
754
|
-
` ${pad("System/info:", 20)} ${systemTokens.toLocaleString().padStart(8)} tokens (${pct(systemTokens)})`,
|
|
755
|
-
"",
|
|
756
|
-
` ${pad("Total used:", 20)} ${totalTokens.toLocaleString().padStart(8)} tokens (${pct(totalTokens)})`,
|
|
757
|
-
` ${pad("Free:", 20)} ${freeTokens.toLocaleString().padStart(8)} tokens (${pct(freeTokens)})`,
|
|
758
|
-
"",
|
|
759
|
-
` ${bar} ${Math.round(usage * 100)}%`,
|
|
760
|
-
"",
|
|
761
|
-
` Messages: ${ctx.messages.length} | Compress at: ${Math.round(ctxWindow * 0.8).toLocaleString()} (80%)`,
|
|
762
|
-
];
|
|
763
|
-
return { output: lines.join("\n"), handled: true };
|
|
764
|
-
});
|
|
765
|
-
register("mcp", "Show MCP server status", () => {
|
|
766
|
-
const mcp = connectedMcpServers();
|
|
767
|
-
if (mcp.length === 0) {
|
|
768
|
-
return {
|
|
769
|
-
output: "No MCP servers connected.\nConfigure in .oh/config.yaml under mcpServers.\nRun /mcp-registry to browse available servers.",
|
|
770
|
-
handled: true,
|
|
771
|
-
};
|
|
772
|
-
}
|
|
773
|
-
const lines = [`MCP Servers (${mcp.length} connected):\n`];
|
|
774
|
-
for (const name of mcp) {
|
|
775
|
-
lines.push(` ✓ ${name}`);
|
|
776
|
-
}
|
|
777
|
-
lines.push("\nRun /mcp-registry to browse and add more servers.");
|
|
778
|
-
return { output: lines.join("\n"), handled: true };
|
|
779
|
-
});
|
|
780
|
-
register("mcp-registry", "Browse and add MCP servers from the curated registry", (args) => {
|
|
781
|
-
const { searchRegistry, formatRegistry, generateConfigBlock, MCP_REGISTRY } = require("../mcp/registry.js");
|
|
782
|
-
const query = args.trim();
|
|
783
|
-
if (!query) {
|
|
784
|
-
// Show full registry
|
|
785
|
-
const output = `MCP Server Registry (${MCP_REGISTRY.length} servers)\n${"─".repeat(50)}\n\n${formatRegistry()}\n\nUsage:\n /mcp-registry <name> Show install config for a server\n /mcp-registry <keyword> Search by name, description, or category`;
|
|
786
|
-
return { output, handled: true };
|
|
787
|
-
}
|
|
788
|
-
// Search or show specific server
|
|
789
|
-
const results = searchRegistry(query);
|
|
790
|
-
if (results.length === 0) {
|
|
791
|
-
return { output: `No MCP servers found matching "${query}".`, handled: true };
|
|
792
|
-
}
|
|
793
|
-
if (results.length === 1) {
|
|
794
|
-
// Show install instructions
|
|
795
|
-
const entry = results[0];
|
|
796
|
-
const config = generateConfigBlock(entry);
|
|
797
|
-
const envNote = entry.envVars?.length
|
|
798
|
-
? `\n\nRequired environment variables:\n${entry.envVars.map((v) => ` - ${v}`).join("\n")}`
|
|
799
|
-
: "";
|
|
800
|
-
return {
|
|
801
|
-
output: `${entry.name} — ${entry.description}\nPackage: ${entry.package}\nRisk: ${entry.riskLevel ?? "medium"}${envNote}\n\nAdd to .oh/config.yaml under mcpServers:\n\n${config}`,
|
|
802
|
-
handled: true,
|
|
803
|
-
};
|
|
804
|
-
}
|
|
805
|
-
// Multiple results
|
|
806
|
-
return { output: `Found ${results.length} servers:\n\n${formatRegistry(results)}`, handled: true };
|
|
807
|
-
});
|
|
808
|
-
function setPinned(args, ctx, pinned) {
|
|
809
|
-
const idx = parseInt(args.trim(), 10);
|
|
810
|
-
if (Number.isNaN(idx) || idx < 1 || idx > ctx.messages.length) {
|
|
811
|
-
return { output: `Usage: /${pinned ? "pin" : "unpin"} <message-number> (1-${ctx.messages.length})`, handled: true };
|
|
812
|
-
}
|
|
813
|
-
// Immutable update — replace message with updated meta
|
|
814
|
-
const updatedMessages = ctx.messages.map((m, i) => (i === idx - 1 ? { ...m, meta: { ...m.meta, pinned } } : m));
|
|
815
|
-
return {
|
|
816
|
-
output: `Message #${idx} ${pinned ? "pinned" : "unpinned"}.`,
|
|
817
|
-
handled: true,
|
|
818
|
-
compactedMessages: updatedMessages,
|
|
819
|
-
};
|
|
820
|
-
}
|
|
821
|
-
register("pin", "Pin a message (survives /compact)", (args, ctx) => setPinned(args, ctx, true));
|
|
822
|
-
register("unpin", "Unpin a message", (args, ctx) => setPinned(args, ctx, false));
|
|
823
|
-
register("plugins", "Manage plugins: list, search, install, uninstall, marketplace", (args) => {
|
|
824
|
-
const { discoverPlugins, discoverSkills } = require("../harness/plugins.js");
|
|
825
|
-
const { searchMarketplace, installPlugin, uninstallPlugin, getInstalledPlugins, listMarketplaces, addMarketplace, removeMarketplace, formatMarketplaceSearch, formatInstalledPlugins, } = require("../harness/marketplace.js");
|
|
826
|
-
const parts = args.trim().split(/\s+/);
|
|
827
|
-
const subcommand = parts[0] ?? "";
|
|
828
|
-
const rest = parts.slice(1).join(" ");
|
|
829
|
-
// /plugins marketplace add <source>
|
|
830
|
-
if (subcommand === "marketplace") {
|
|
831
|
-
const action = parts[1];
|
|
832
|
-
const source = parts.slice(2).join(" ");
|
|
833
|
-
if (action === "add" && source) {
|
|
834
|
-
const mp = addMarketplace(source);
|
|
835
|
-
if (mp)
|
|
836
|
-
return { output: `Added marketplace "${mp.name}" (${mp.plugins.length} plugins)`, handled: true };
|
|
837
|
-
return { output: `Failed to add marketplace from "${source}"`, handled: true };
|
|
838
|
-
}
|
|
839
|
-
if (action === "remove" && source) {
|
|
840
|
-
return {
|
|
841
|
-
output: removeMarketplace(source) ? `Removed marketplace "${source}"` : `Marketplace "${source}" not found`,
|
|
842
|
-
handled: true,
|
|
843
|
-
};
|
|
844
|
-
}
|
|
845
|
-
// List marketplaces
|
|
846
|
-
const mps = listMarketplaces();
|
|
847
|
-
if (mps.length === 0) {
|
|
848
|
-
return {
|
|
849
|
-
output: "No marketplaces configured.\n\nAdd one:\n /plugins marketplace add owner/repo\n /plugins marketplace add https://example.com/plugins",
|
|
850
|
-
handled: true,
|
|
851
|
-
};
|
|
852
|
-
}
|
|
853
|
-
const lines = [`Marketplaces (${mps.length}):\n`];
|
|
854
|
-
for (const mp of mps) {
|
|
855
|
-
lines.push(` ${mp.name} — ${mp.plugins.length} plugins`);
|
|
856
|
-
}
|
|
857
|
-
return { output: lines.join("\n"), handled: true };
|
|
858
|
-
}
|
|
859
|
-
// /plugins search <query>
|
|
860
|
-
if (subcommand === "search") {
|
|
861
|
-
const query = rest || "all";
|
|
862
|
-
const results = searchMarketplace(query === "all" ? "" : query);
|
|
863
|
-
return { output: formatMarketplaceSearch(results), handled: true };
|
|
864
|
-
}
|
|
865
|
-
// /plugins install <name>
|
|
866
|
-
if (subcommand === "install" && rest) {
|
|
867
|
-
const [name, marketplace] = rest.split("@");
|
|
868
|
-
const result = installPlugin(name, marketplace);
|
|
869
|
-
if (result) {
|
|
870
|
-
return {
|
|
871
|
-
output: `Installed ${result.name}@${result.version} from ${result.marketplace}\nCached at: ${result.cachePath}`,
|
|
872
|
-
handled: true,
|
|
873
|
-
};
|
|
874
|
-
}
|
|
875
|
-
return {
|
|
876
|
-
output: `Failed to install "${rest}". Is it listed in a marketplace?\nRun /plugins search ${name} to check.`,
|
|
877
|
-
handled: true,
|
|
878
|
-
};
|
|
879
|
-
}
|
|
880
|
-
// /plugins uninstall <name>
|
|
881
|
-
if (subcommand === "uninstall" && rest) {
|
|
882
|
-
return { output: uninstallPlugin(rest) ? `Uninstalled "${rest}"` : `Plugin "${rest}" not found`, handled: true };
|
|
883
|
-
}
|
|
884
|
-
// /plugins (no args) — show everything
|
|
885
|
-
const plugins = discoverPlugins();
|
|
886
|
-
const skills = discoverSkills();
|
|
887
|
-
const marketplacePlugins = getInstalledPlugins();
|
|
888
|
-
const lines = [];
|
|
889
|
-
if (marketplacePlugins.length > 0) {
|
|
890
|
-
lines.push(formatInstalledPlugins(marketplacePlugins));
|
|
891
|
-
lines.push("");
|
|
892
|
-
}
|
|
893
|
-
if (plugins.length > 0) {
|
|
894
|
-
lines.push(`Local Plugins (${plugins.length}):`);
|
|
895
|
-
for (const p of plugins) {
|
|
896
|
-
lines.push(` ${p.name}@${p.version} — ${p.description || "no description"}`);
|
|
897
|
-
}
|
|
898
|
-
lines.push("");
|
|
899
|
-
}
|
|
900
|
-
if (skills.length > 0) {
|
|
901
|
-
lines.push(`Skills (${skills.length}):`);
|
|
902
|
-
for (const s of skills) {
|
|
903
|
-
lines.push(` ${s.source}:${s.name} — ${s.description || ""}`);
|
|
904
|
-
}
|
|
905
|
-
lines.push("");
|
|
906
|
-
}
|
|
907
|
-
if (lines.length === 0) {
|
|
908
|
-
lines.push("No plugins or skills installed.");
|
|
909
|
-
}
|
|
910
|
-
lines.push("");
|
|
911
|
-
lines.push("Commands:");
|
|
912
|
-
lines.push(" /plugins search <query> Search marketplaces");
|
|
913
|
-
lines.push(" /plugins install <name> Install from marketplace");
|
|
914
|
-
lines.push(" /plugins uninstall <name> Remove a plugin");
|
|
915
|
-
lines.push(" /plugins marketplace add <src> Add a marketplace");
|
|
916
|
-
lines.push(" /plugins marketplace List marketplaces");
|
|
917
|
-
return { output: lines.join("\n"), handled: true };
|
|
918
|
-
});
|
|
919
|
-
// ── Project Init ──
|
|
920
|
-
register("init", "Initialize project with .oh/ config", () => {
|
|
921
|
-
const ohDir = join(process.cwd(), ".oh");
|
|
922
|
-
if (existsSync(ohDir)) {
|
|
923
|
-
return { output: ".oh/ directory already exists. Project is already initialized.", handled: true };
|
|
924
|
-
}
|
|
925
|
-
mkdirSync(ohDir, { recursive: true });
|
|
926
|
-
const rulesPath = join(ohDir, "RULES.md");
|
|
927
|
-
if (!existsSync(rulesPath)) {
|
|
928
|
-
writeFileSync(rulesPath, `# Project Rules
|
|
929
|
-
|
|
930
|
-
<!-- Add project-specific instructions here. These are loaded into every session. -->
|
|
931
|
-
<!-- Examples: coding conventions, testing requirements, deployment guidelines. -->
|
|
932
|
-
`);
|
|
933
|
-
}
|
|
934
|
-
const configPath = join(ohDir, "config.yaml");
|
|
935
|
-
if (!existsSync(configPath)) {
|
|
936
|
-
writeFileSync(configPath, `# OpenHarness project config
|
|
937
|
-
# provider: ollama
|
|
938
|
-
# model: llama3
|
|
939
|
-
# permissionMode: ask
|
|
940
|
-
`);
|
|
941
|
-
}
|
|
942
|
-
return {
|
|
943
|
-
output: `Initialized .oh/ with:\n .oh/RULES.md — project rules\n .oh/config.yaml — project config\n\nEdit these files to customize your project.`,
|
|
944
|
-
handled: true,
|
|
945
|
-
};
|
|
946
|
-
});
|
|
947
|
-
// ── Permissions ──
|
|
948
|
-
register("permissions", "View or change permission mode", (args, ctx) => {
|
|
949
|
-
const mode = args.trim().toLowerCase();
|
|
950
|
-
if (!mode) {
|
|
951
|
-
return {
|
|
952
|
-
output: `Current permission mode: ${ctx.permissionMode}\n\nAvailable modes:\n ask Prompt for medium/high risk (default)\n trust Auto-approve everything\n deny Only low-risk read-only\n acceptEdits Auto-approve file edits\n plan Read-only mode\n auto Auto-approve, block dangerous bash\n bypassPermissions CI/CD only`,
|
|
953
|
-
handled: true,
|
|
954
|
-
};
|
|
955
|
-
}
|
|
956
|
-
const valid = ["ask", "trust", "deny", "acceptedits", "plan", "auto", "bypasspermissions"];
|
|
957
|
-
if (!valid.includes(mode)) {
|
|
958
|
-
return { output: `Unknown mode: ${mode}. Valid: ${valid.join(", ")}`, handled: true };
|
|
959
|
-
}
|
|
960
|
-
return {
|
|
961
|
-
output: `Permission mode set to: ${mode}\n(Note: takes effect for new tool calls in this session)`,
|
|
962
|
-
handled: true,
|
|
963
|
-
};
|
|
964
|
-
});
|
|
965
|
-
register("allowed-tools", "View tool permission rules", () => {
|
|
966
|
-
const config = readOhConfig();
|
|
967
|
-
const rules = config?.toolPermissions;
|
|
968
|
-
if (!rules || rules.length === 0) {
|
|
969
|
-
return {
|
|
970
|
-
output: 'No custom tool permission rules configured.\n\nAdd rules to .oh/config.yaml:\n\ntoolPermissions:\n - tool: Bash\n action: ask\n pattern: "^rm .*"',
|
|
971
|
-
handled: true,
|
|
972
|
-
};
|
|
973
|
-
}
|
|
974
|
-
const lines = rules.map((r) => {
|
|
975
|
-
const parts = [` ${r.tool}: ${r.action}`];
|
|
976
|
-
if (r.pattern)
|
|
977
|
-
parts.push(`(pattern: ${r.pattern})`);
|
|
978
|
-
return parts.join(" ");
|
|
979
|
-
});
|
|
980
|
-
return { output: `Tool permission rules:\n${lines.join("\n")}`, handled: true };
|
|
981
|
-
});
|
|
982
|
-
register("rebuild-sessions", "Rebuild session search index", () => {
|
|
983
|
-
// Fire async rebuild, return immediately with status
|
|
984
|
-
import("../harness/session-db.js")
|
|
985
|
-
.then(({ openSessionDb, rebuildIndex, closeSessionDb }) => {
|
|
986
|
-
const db = openSessionDb();
|
|
987
|
-
const count = rebuildIndex(db);
|
|
988
|
-
closeSessionDb(db);
|
|
989
|
-
console.log(`Rebuilt session search index: ${count} sessions indexed.`);
|
|
990
|
-
})
|
|
991
|
-
.catch((err) => {
|
|
992
|
-
console.log(`Failed to rebuild index: ${err.message}`);
|
|
993
|
-
});
|
|
994
|
-
return { output: "Rebuilding session search index...", handled: true };
|
|
995
|
-
});
|
|
996
|
-
// ── Skill Management ──
|
|
997
|
-
register("skill-create", "Create a new skill file", (args) => {
|
|
998
|
-
const name = args.trim();
|
|
999
|
-
if (!name)
|
|
1000
|
-
return { output: "Usage: /skill-create <name>", handled: true };
|
|
1001
|
-
if (name.includes("..") || name.includes("/") || name.includes("\\")) {
|
|
1002
|
-
return { output: "Error: Invalid skill name.", handled: true };
|
|
1003
|
-
}
|
|
1004
|
-
const dir = join(process.cwd(), ".oh", "skills");
|
|
1005
|
-
mkdirSync(dir, { recursive: true });
|
|
1006
|
-
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
1007
|
-
const filePath = join(dir, `${slug}.md`);
|
|
1008
|
-
if (existsSync(filePath)) {
|
|
1009
|
-
return { output: `Skill "${slug}" already exists at ${filePath}`, handled: true };
|
|
1010
|
-
}
|
|
1011
|
-
const template = `---
|
|
1012
|
-
name: ${slug}
|
|
1013
|
-
description: TODO — describe what this skill does
|
|
1014
|
-
trigger: ${slug}
|
|
1015
|
-
---
|
|
1016
|
-
|
|
1017
|
-
# ${name}
|
|
1018
|
-
|
|
1019
|
-
## When to Use
|
|
1020
|
-
Describe when this skill should be triggered.
|
|
1021
|
-
|
|
1022
|
-
## Procedure
|
|
1023
|
-
1. Step one
|
|
1024
|
-
2. Step two
|
|
1025
|
-
3. Step three
|
|
1026
|
-
|
|
1027
|
-
## Pitfalls
|
|
1028
|
-
- Common mistakes to avoid
|
|
1029
|
-
|
|
1030
|
-
## Verification
|
|
1031
|
-
How to confirm the skill worked correctly.
|
|
1032
|
-
`;
|
|
1033
|
-
writeFileSync(filePath, template);
|
|
1034
|
-
return { output: `Created skill: ${filePath}\nEdit the file to customize it.`, handled: true };
|
|
1035
|
-
});
|
|
1036
|
-
register("skill-delete", "Delete a skill file", (args) => {
|
|
1037
|
-
const name = args.trim();
|
|
1038
|
-
if (!name)
|
|
1039
|
-
return { output: "Usage: /skill-delete <name>", handled: true };
|
|
1040
|
-
const { findSkill } = require("../harness/plugins.js");
|
|
1041
|
-
const skill = findSkill(name);
|
|
1042
|
-
if (!skill)
|
|
1043
|
-
return { output: `Skill "${name}" not found.`, handled: true };
|
|
1044
|
-
try {
|
|
1045
|
-
const { unlinkSync } = require("node:fs");
|
|
1046
|
-
unlinkSync(skill.filePath);
|
|
1047
|
-
return { output: `Deleted skill: ${skill.filePath}`, handled: true };
|
|
1048
|
-
}
|
|
1049
|
-
catch (err) {
|
|
1050
|
-
return { output: `Error deleting skill: ${err.message}`, handled: true };
|
|
1051
|
-
}
|
|
1052
|
-
});
|
|
1053
|
-
register("skill-edit", "Show skill file path for editing", (args) => {
|
|
1054
|
-
const name = args.trim();
|
|
1055
|
-
if (!name)
|
|
1056
|
-
return { output: "Usage: /skill-edit <name>", handled: true };
|
|
1057
|
-
const { findSkill } = require("../harness/plugins.js");
|
|
1058
|
-
const skill = findSkill(name);
|
|
1059
|
-
if (!skill)
|
|
1060
|
-
return { output: `Skill "${name}" not found.`, handled: true };
|
|
1061
|
-
return { output: `Skill file: ${skill.filePath}\nEdit this file to update the skill.`, handled: true };
|
|
1062
|
-
});
|
|
26
|
+
// Register all command groups
|
|
27
|
+
registerSessionCommands(register);
|
|
28
|
+
registerGitCommands(register);
|
|
29
|
+
registerInfoCommands(register, () => commands);
|
|
30
|
+
registerSettingsCommands(register);
|
|
31
|
+
registerAICommands(register);
|
|
32
|
+
registerSkillCommands(register);
|
|
1063
33
|
// ── Command Parser ──
|
|
1064
34
|
/**
|
|
1065
35
|
* Check if input is a slash command. If so, execute it.
|