@zhijiewang/openharness 2.4.0 → 2.5.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/index.js +44 -0
- package/dist/components/InitWizard.js +5 -3
- package/dist/harness/config.d.ts +2 -0
- 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/fallback.js +2 -3
- package/dist/query/index.js +30 -6
- package/dist/query/tools.js +11 -0
- package/dist/query/types.d.ts +4 -0
- package/dist/repl.js +3 -1
- package/dist/services/SkillExtractor.js +2 -0
- package/dist/tools/SkillTool/index.js +26 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ AI coding agent in your terminal. Works with any LLM -- free local models or clo
|
|
|
21
21
|
<img src="assets/openharness_v0.11.1_4.gif" alt="OpenHarness demo" width="800" />
|
|
22
22
|
</p>
|
|
23
23
|
|
|
24
|
-
[](https://www.npmjs.com/package/@zhijiewang/openharness) [](https://www.npmjs.com/package/@zhijiewang/openharness) [](LICENSE) ](https://www.npmjs.com/package/@zhijiewang/openharness) [](https://www.npmjs.com/package/@zhijiewang/openharness) [](LICENSE)     [](https://github.com/zhijiewong/openharness) [](https://github.com/zhijiewong/openharness/issues) [](https://github.com/zhijiewong/openharness/pulls)
|
|
25
25
|
|
|
26
26
|
---
|
|
27
27
|
|
|
@@ -84,7 +84,7 @@ Most AI coding agents are locked to one provider or cost $20+/month. OpenHarness
|
|
|
84
84
|
|---|---|---|---|---|
|
|
85
85
|
| Any LLM | Yes (Ollama, OpenAI, Anthropic, OpenRouter, any OpenAI-compatible) | Anthropic only | Yes | Yes |
|
|
86
86
|
| Free local models | Ollama native | No | Yes | Yes |
|
|
87
|
-
| Tools |
|
|
87
|
+
| Tools | 41 with permission gates | 43+ | File-focused | 20+ |
|
|
88
88
|
| Permission modes | 7 (ask, trust, deny, acceptEdits, plan, auto, bypass) | 7 | Basic | Basic |
|
|
89
89
|
| Git integration | Auto-commit + /undo + /rewind checkpoints | Yes | Deep git | Basic |
|
|
90
90
|
| Slash commands | 42+ built-in | 80+ | Some | Some |
|
package/dist/Tool.d.ts
CHANGED
|
@@ -23,6 +23,8 @@ export type ToolContext = {
|
|
|
23
23
|
permissionMode?: PermissionMode;
|
|
24
24
|
/** Ask the user a question; resolves with their answer string */
|
|
25
25
|
askUserQuestion?: (question: string, options?: string[]) => Promise<string>;
|
|
26
|
+
/** Auto-commit after file-modifying tools */
|
|
27
|
+
gitCommitPerTool?: boolean;
|
|
26
28
|
};
|
|
27
29
|
export type Tool<Input extends z.ZodType = z.ZodType> = {
|
|
28
30
|
readonly name: string;
|
package/dist/commands/index.js
CHANGED
|
@@ -1060,6 +1060,50 @@ register("skill-edit", "Show skill file path for editing", (args) => {
|
|
|
1060
1060
|
return { output: `Skill "${name}" not found.`, handled: true };
|
|
1061
1061
|
return { output: `Skill file: ${skill.filePath}\nEdit this file to update the skill.`, handled: true };
|
|
1062
1062
|
});
|
|
1063
|
+
register("skill-search", "Search the skills registry", (args) => {
|
|
1064
|
+
const query = args.trim();
|
|
1065
|
+
if (!query)
|
|
1066
|
+
return { output: "Usage: /skill-search <query>", handled: true };
|
|
1067
|
+
// Async search — fire and return message
|
|
1068
|
+
import("../harness/skill-registry.js").then(async ({ fetchRegistry, searchRegistry }) => {
|
|
1069
|
+
try {
|
|
1070
|
+
const registry = await fetchRegistry();
|
|
1071
|
+
const results = searchRegistry(registry, query);
|
|
1072
|
+
if (results.length === 0) {
|
|
1073
|
+
console.log(`No skills found matching "${query}".`);
|
|
1074
|
+
}
|
|
1075
|
+
else {
|
|
1076
|
+
const lines = results.map((s) => ` ${s.name.padEnd(20)} ${s.description} [${s.tags.join(", ")}]`);
|
|
1077
|
+
console.log(`Found ${results.length} skill(s):\n${lines.join("\n")}\n\nInstall: /skill-install <name>`);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
catch (err) {
|
|
1081
|
+
console.log(`Registry search failed: ${err.message}`);
|
|
1082
|
+
}
|
|
1083
|
+
});
|
|
1084
|
+
return { output: "Searching skills registry...", handled: true };
|
|
1085
|
+
});
|
|
1086
|
+
register("skill-install", "Install a skill from the registry", (args) => {
|
|
1087
|
+
const name = args.trim();
|
|
1088
|
+
if (!name)
|
|
1089
|
+
return { output: "Usage: /skill-install <name>", handled: true };
|
|
1090
|
+
import("../harness/skill-registry.js").then(async ({ fetchRegistry, installSkill }) => {
|
|
1091
|
+
try {
|
|
1092
|
+
const registry = await fetchRegistry();
|
|
1093
|
+
const skill = registry.skills.find((s) => s.name.toLowerCase() === name.toLowerCase());
|
|
1094
|
+
if (!skill) {
|
|
1095
|
+
console.log(`Skill "${name}" not found in registry. Try /skill-search first.`);
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
const path = await installSkill(skill);
|
|
1099
|
+
console.log(`Installed skill "${skill.name}" to ${path}`);
|
|
1100
|
+
}
|
|
1101
|
+
catch (err) {
|
|
1102
|
+
console.log(`Installation failed: ${err.message}`);
|
|
1103
|
+
}
|
|
1104
|
+
});
|
|
1105
|
+
return { output: `Installing skill "${name}"...`, handled: true };
|
|
1106
|
+
});
|
|
1063
1107
|
// ── Command Parser ──
|
|
1064
1108
|
/**
|
|
1065
1109
|
* Check if input is a slash command. If so, execute it.
|
|
@@ -86,7 +86,7 @@ export default function InitWizard({ onDone }) {
|
|
|
86
86
|
const [testStatus, setTestStatus] = useState("testing");
|
|
87
87
|
const [testError, setTestError] = useState("");
|
|
88
88
|
const [permIdx, setPermIdx] = useState(0);
|
|
89
|
-
const [
|
|
89
|
+
const [_hatchGotchi, setHatchGotchi] = useState(false);
|
|
90
90
|
const [showSetup, setShowSetup] = useState(false);
|
|
91
91
|
const [suggestedMcp, setSuggestedMcp] = useState([]);
|
|
92
92
|
const [selectedMcp, setSelectedMcp] = useState(new Set());
|
|
@@ -175,7 +175,9 @@ export default function InitWizard({ onDone }) {
|
|
|
175
175
|
if (input === "n" || input === "N")
|
|
176
176
|
writeFinal();
|
|
177
177
|
}
|
|
178
|
-
},
|
|
178
|
+
},
|
|
179
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: runTest/writeFinal declared below, providerIdx intentionally omitted
|
|
180
|
+
[step, providerIdx, provider, modelIdx, availableModels, model, suggestedMcp, mcpIdx]));
|
|
179
181
|
// ── Connection test ──
|
|
180
182
|
const runTest = async (prov, key, url) => {
|
|
181
183
|
setTestStatus("testing");
|
|
@@ -234,7 +236,7 @@ export default function InitWizard({ onDone }) {
|
|
|
234
236
|
});
|
|
235
237
|
setStep("done");
|
|
236
238
|
setTimeout(() => onDone?.(), 1500);
|
|
237
|
-
}, [provider, model, availableModels, modelIdx, permIdx, apiKey, baseUrl, selectedMcp]);
|
|
239
|
+
}, [provider, model, availableModels, modelIdx, permIdx, apiKey, baseUrl, selectedMcp, onDone]);
|
|
238
240
|
// ── Render ──
|
|
239
241
|
if (showSetup) {
|
|
240
242
|
return (_jsx(CybergotchiSetup, { onComplete: () => {
|
package/dist/harness/config.d.ts
CHANGED
|
@@ -75,6 +75,8 @@ export type OhConfig = {
|
|
|
75
75
|
apiKey?: string;
|
|
76
76
|
baseUrl?: string;
|
|
77
77
|
}>;
|
|
78
|
+
/** Auto-commit after each file-modifying tool execution */
|
|
79
|
+
gitCommitPerTool?: boolean;
|
|
78
80
|
/** Effort level for LLM reasoning depth */
|
|
79
81
|
effortLevel?: "low" | "medium" | "high" | "max";
|
|
80
82
|
/** Opt-in telemetry (default: off) */
|
package/dist/harness/memory.js
CHANGED
|
@@ -283,7 +283,34 @@ export function consolidateMemories() {
|
|
|
283
283
|
// Refresh MEMORY.md index after pruning
|
|
284
284
|
updateMemoryIndex(PROJECT_MEMORY_DIR);
|
|
285
285
|
updateMemoryIndex(GLOBAL_MEMORY_DIR);
|
|
286
|
-
|
|
286
|
+
// Skill decay: prune auto-extracted skills unused for 60 days
|
|
287
|
+
let prunedSkills = 0;
|
|
288
|
+
try {
|
|
289
|
+
const skillsAutoDir = join(".oh", "skills", "auto");
|
|
290
|
+
if (existsSync(skillsAutoDir)) {
|
|
291
|
+
const SKILL_DECAY_MS = 60 * 24 * 60 * 60 * 1000; // 60 days
|
|
292
|
+
for (const file of readdirSync(skillsAutoDir).filter((f) => f.endsWith(".md"))) {
|
|
293
|
+
try {
|
|
294
|
+
const raw = readFileSync(join(skillsAutoDir, file), "utf-8");
|
|
295
|
+
const usedMatch = raw.match(/^timesUsed:\s*(\d+)$/m);
|
|
296
|
+
const extractedMatch = raw.match(/^extractedAt:\s*(\d+)$/m);
|
|
297
|
+
const timesUsed = usedMatch ? parseInt(usedMatch[1], 10) : 0;
|
|
298
|
+
const extractedAt = extractedMatch ? parseInt(extractedMatch[1], 10) : Date.now();
|
|
299
|
+
if (timesUsed < 2 && Date.now() - extractedAt > SKILL_DECAY_MS) {
|
|
300
|
+
unlinkSync(join(skillsAutoDir, file));
|
|
301
|
+
prunedSkills++;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
/* skip unreadable skill files */
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
/* skill pruning is optional */
|
|
312
|
+
}
|
|
313
|
+
return { total: all.length, pruned: prunedCount + prunedSkills, decayed: decayedCount };
|
|
287
314
|
}
|
|
288
315
|
// ── User Profile ──
|
|
289
316
|
const USER_PROFILE_FILE = "USER.md";
|
|
@@ -19,6 +19,8 @@ export type SkillMetadata = {
|
|
|
19
19
|
content: string;
|
|
20
20
|
filePath: string;
|
|
21
21
|
source: "project" | "global" | "plugin";
|
|
22
|
+
/** When false, skill is hidden from system prompt until explicitly invoked */
|
|
23
|
+
invokeModel: boolean;
|
|
22
24
|
};
|
|
23
25
|
export type PluginManifest = {
|
|
24
26
|
name: string;
|
package/dist/harness/plugins.js
CHANGED
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
* 2. ~/.oh/skills/ (global)
|
|
11
11
|
* 3. node_modules packages with "openharness-plugin" keyword
|
|
12
12
|
*/
|
|
13
|
-
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
13
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
|
-
import {
|
|
15
|
+
import { join, relative } from "node:path";
|
|
16
16
|
const PROJECT_SKILLS_DIR = join(".oh", "skills");
|
|
17
17
|
const GLOBAL_SKILLS_DIR = join(homedir(), ".oh", "skills");
|
|
18
18
|
/** Parse YAML frontmatter from a skill markdown file */
|
|
@@ -34,24 +34,54 @@ function parseSkillFrontmatter(content) {
|
|
|
34
34
|
const toolsMatch = frontmatter.match(/^tools:\s*\[(.+)\]$/m);
|
|
35
35
|
if (toolsMatch)
|
|
36
36
|
result.tools = toolsMatch[1].split(",").map((t) => t.trim());
|
|
37
|
+
// Also parse allowedTools (used by built-in skills) and merge with tools
|
|
38
|
+
const allowedToolsMatch = frontmatter.match(/^allowedTools:\s*\[(.+)\]$/m);
|
|
39
|
+
if (allowedToolsMatch) {
|
|
40
|
+
const allowed = allowedToolsMatch[1].split(",").map((t) => t.trim());
|
|
41
|
+
result.tools = result.tools ? [...new Set([...result.tools, ...allowed])] : allowed;
|
|
42
|
+
}
|
|
37
43
|
const argsMatch = frontmatter.match(/^args:\s*\[(.+)\]$/m);
|
|
38
44
|
if (argsMatch)
|
|
39
45
|
result.args = argsMatch[1].split(",").map((a) => a.trim());
|
|
46
|
+
// invokeModel: false OR disable-model-invocation: true → hidden from system prompt
|
|
47
|
+
if (frontmatter.match(/^invokeModel:\s*false$/m) || frontmatter.match(/^disable-model-invocation:\s*true$/m)) {
|
|
48
|
+
result.invokeModel = false;
|
|
49
|
+
}
|
|
40
50
|
return result;
|
|
41
51
|
}
|
|
42
|
-
/**
|
|
43
|
-
function
|
|
52
|
+
/** Recursively collect all .md files from a directory tree */
|
|
53
|
+
function walkMdFiles(dir) {
|
|
44
54
|
if (!existsSync(dir))
|
|
45
55
|
return [];
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
56
|
+
const results = [];
|
|
57
|
+
for (const entry of readdirSync(dir)) {
|
|
58
|
+
const full = join(dir, entry);
|
|
59
|
+
try {
|
|
60
|
+
if (statSync(full).isDirectory()) {
|
|
61
|
+
results.push(...walkMdFiles(full));
|
|
62
|
+
}
|
|
63
|
+
else if (entry.endsWith(".md")) {
|
|
64
|
+
results.push(full);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
/* skip unreadable */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return results;
|
|
72
|
+
}
|
|
73
|
+
/** Load skills from a directory (recursively walks subdirectories) */
|
|
74
|
+
function loadSkillsFromDir(dir, source) {
|
|
75
|
+
const files = walkMdFiles(dir);
|
|
76
|
+
return files
|
|
77
|
+
.map((filePath) => {
|
|
50
78
|
try {
|
|
51
79
|
const content = readFileSync(filePath, "utf-8");
|
|
52
80
|
const meta = parseSkillFrontmatter(content);
|
|
81
|
+
// Derive name from relative path if not in frontmatter
|
|
82
|
+
const relName = relative(dir, filePath).replace(/\.md$/, "").replace(/\\/g, "/");
|
|
53
83
|
return {
|
|
54
|
-
name: meta.name ||
|
|
84
|
+
name: meta.name || relName,
|
|
55
85
|
description: meta.description || "",
|
|
56
86
|
trigger: meta.trigger,
|
|
57
87
|
tools: meta.tools,
|
|
@@ -59,6 +89,7 @@ function loadSkillsFromDir(dir, source) {
|
|
|
59
89
|
content,
|
|
60
90
|
filePath,
|
|
61
91
|
source,
|
|
92
|
+
invokeModel: meta.invokeModel ?? true,
|
|
62
93
|
};
|
|
63
94
|
}
|
|
64
95
|
catch {
|
|
@@ -172,9 +203,11 @@ export function discoverPlugins() {
|
|
|
172
203
|
}
|
|
173
204
|
/** Build a prompt listing available skills for the LLM */
|
|
174
205
|
export function skillsToPrompt(skills) {
|
|
175
|
-
|
|
206
|
+
// Only include skills with invokeModel !== false (hidden skills excluded from prompt)
|
|
207
|
+
const visible = skills.filter((s) => s.invokeModel !== false);
|
|
208
|
+
if (visible.length === 0)
|
|
176
209
|
return "";
|
|
177
|
-
const lines =
|
|
210
|
+
const lines = visible.map((s) => `- ${s.name}: ${s.description}${s.trigger ? ` (auto-trigger: "${s.trigger}")` : ""}`);
|
|
178
211
|
return `# Available Skills\nUse the Skill tool to invoke these:\n${lines.join("\n")}`;
|
|
179
212
|
}
|
|
180
213
|
//# sourceMappingURL=plugins.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skills Registry — search and install community skills from a remote registry.
|
|
3
|
+
*/
|
|
4
|
+
export type RegistrySkill = {
|
|
5
|
+
name: string;
|
|
6
|
+
description: string;
|
|
7
|
+
author: string;
|
|
8
|
+
version: string;
|
|
9
|
+
source: string;
|
|
10
|
+
tags: string[];
|
|
11
|
+
};
|
|
12
|
+
export type Registry = {
|
|
13
|
+
skills: RegistrySkill[];
|
|
14
|
+
};
|
|
15
|
+
/** Fetch the registry from remote URL */
|
|
16
|
+
export declare function fetchRegistry(url?: string): Promise<Registry>;
|
|
17
|
+
/** Search registry by query (matches name, description, tags) */
|
|
18
|
+
export declare function searchRegistry(registry: Registry, query: string): RegistrySkill[];
|
|
19
|
+
/** Install a skill from the registry to ~/.oh/skills/ */
|
|
20
|
+
export declare function installSkill(skill: RegistrySkill): Promise<string>;
|
|
21
|
+
//# sourceMappingURL=skill-registry.d.ts.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skills Registry — search and install community skills from a remote registry.
|
|
3
|
+
*/
|
|
4
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
const DEFAULT_REGISTRY_URL = "https://raw.githubusercontent.com/zhijiewong/openharness/main/data/registry.json";
|
|
8
|
+
const GLOBAL_SKILLS_DIR = join(homedir(), ".oh", "skills");
|
|
9
|
+
/** Fetch the registry from remote URL */
|
|
10
|
+
export async function fetchRegistry(url = DEFAULT_REGISTRY_URL) {
|
|
11
|
+
const response = await fetch(url);
|
|
12
|
+
if (!response.ok)
|
|
13
|
+
throw new Error(`Failed to fetch registry: ${response.status}`);
|
|
14
|
+
return (await response.json());
|
|
15
|
+
}
|
|
16
|
+
/** Search registry by query (matches name, description, tags) */
|
|
17
|
+
export function searchRegistry(registry, query) {
|
|
18
|
+
const q = query.toLowerCase();
|
|
19
|
+
return registry.skills.filter((s) => s.name.toLowerCase().includes(q) ||
|
|
20
|
+
s.description.toLowerCase().includes(q) ||
|
|
21
|
+
s.tags.some((t) => t.toLowerCase().includes(q)));
|
|
22
|
+
}
|
|
23
|
+
/** Install a skill from the registry to ~/.oh/skills/ */
|
|
24
|
+
export async function installSkill(skill) {
|
|
25
|
+
const response = await fetch(skill.source);
|
|
26
|
+
if (!response.ok)
|
|
27
|
+
throw new Error(`Failed to download skill: ${response.status}`);
|
|
28
|
+
const content = await response.text();
|
|
29
|
+
mkdirSync(GLOBAL_SKILLS_DIR, { recursive: true });
|
|
30
|
+
const slug = skill.name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
31
|
+
const filePath = join(GLOBAL_SKILLS_DIR, `${slug}.md`);
|
|
32
|
+
writeFileSync(filePath, content);
|
|
33
|
+
return filePath;
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=skill-registry.js.map
|
package/dist/lsp/client.js
CHANGED
|
@@ -11,6 +11,7 @@ export class LspClient {
|
|
|
11
11
|
buffer = "";
|
|
12
12
|
contentLength = -1;
|
|
13
13
|
diagnostics = new Map();
|
|
14
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: set via Object.assign in static factory
|
|
14
15
|
ready = false;
|
|
15
16
|
constructor(proc) {
|
|
16
17
|
this.proc = proc;
|
|
@@ -38,7 +39,7 @@ export class LspClient {
|
|
|
38
39
|
this.buffer = this.buffer.slice(headerEnd + 4);
|
|
39
40
|
continue;
|
|
40
41
|
}
|
|
41
|
-
this.contentLength = parseInt(match[1]);
|
|
42
|
+
this.contentLength = parseInt(match[1], 10);
|
|
42
43
|
this.buffer = this.buffer.slice(headerEnd + 4);
|
|
43
44
|
}
|
|
44
45
|
if (this.buffer.length < this.contentLength)
|
package/dist/main.js
CHANGED
|
@@ -17,10 +17,10 @@ import { Command, Option } from "commander";
|
|
|
17
17
|
import { render } from "ink";
|
|
18
18
|
import { readOhConfig } from "./harness/config.js";
|
|
19
19
|
import { emitHook } from "./harness/hooks.js";
|
|
20
|
-
import { detectProject, projectContextToPrompt } from "./harness/onboarding.js";
|
|
21
|
-
import { createRulesFile, loadRules, loadRulesAsPrompt } from "./harness/rules.js";
|
|
22
20
|
import { loadActiveMemories, memoriesToPrompt, userProfileToPrompt } from "./harness/memory.js";
|
|
21
|
+
import { detectProject, projectContextToPrompt } from "./harness/onboarding.js";
|
|
23
22
|
import { discoverSkills, skillsToPrompt } from "./harness/plugins.js";
|
|
23
|
+
import { createRulesFile, loadRules, loadRulesAsPrompt } from "./harness/rules.js";
|
|
24
24
|
import { listSessions } from "./harness/session.js";
|
|
25
25
|
import { connectedMcpServers, disconnectMcpClients, getMcpInstructions, loadMcpTools } from "./mcp/loader.js";
|
|
26
26
|
import { getAllTools } from "./tools.js";
|
|
@@ -735,6 +735,14 @@ program
|
|
|
735
735
|
const server = new McpServer(tools, context);
|
|
736
736
|
server.start();
|
|
737
737
|
});
|
|
738
|
+
// ── mcp-server (alias for serve, standard MCP server mode) ──
|
|
739
|
+
program
|
|
740
|
+
.command("mcp-server")
|
|
741
|
+
.description("Start as MCP server (stdio JSON-RPC) — alias for serve")
|
|
742
|
+
.action(async () => {
|
|
743
|
+
const { startMcpServer } = await import("./mcp/server-mode.js");
|
|
744
|
+
await startMcpServer();
|
|
745
|
+
});
|
|
738
746
|
// ── schedule ──
|
|
739
747
|
program
|
|
740
748
|
.command("schedule")
|
package/dist/mcp/client.js
CHANGED
|
@@ -6,6 +6,7 @@ export class McpClient {
|
|
|
6
6
|
proc;
|
|
7
7
|
nextId = 1;
|
|
8
8
|
pending = new Map();
|
|
9
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: set via Object.assign in static factory
|
|
9
10
|
ready = false;
|
|
10
11
|
dead = false;
|
|
11
12
|
cfg;
|
|
@@ -139,7 +140,7 @@ export class McpClient {
|
|
|
139
140
|
const id = this.nextId++;
|
|
140
141
|
const req = { jsonrpc: "2.0", id, method, params };
|
|
141
142
|
this.pending.set(id, { resolve, reject });
|
|
142
|
-
this.proc.stdin.write(JSON.stringify(req)
|
|
143
|
+
this.proc.stdin.write(`${JSON.stringify(req)}\n`);
|
|
143
144
|
});
|
|
144
145
|
}
|
|
145
146
|
disconnect() {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Server Mode — expose openHarness tools as an MCP server over stdio.
|
|
3
|
+
* Run: oh mcp-server
|
|
4
|
+
*
|
|
5
|
+
* Thin entry-point that wires getAllTools() into the McpServer class.
|
|
6
|
+
* Each message is a JSON-RPC 2.0 object on a single newline-delimited line.
|
|
7
|
+
* stdin → requests, stdout → responses, stderr → logs.
|
|
8
|
+
*/
|
|
9
|
+
export declare function startMcpServer(): Promise<void>;
|
|
10
|
+
//# sourceMappingURL=server-mode.d.ts.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Server Mode — expose openHarness tools as an MCP server over stdio.
|
|
3
|
+
* Run: oh mcp-server
|
|
4
|
+
*
|
|
5
|
+
* Thin entry-point that wires getAllTools() into the McpServer class.
|
|
6
|
+
* Each message is a JSON-RPC 2.0 object on a single newline-delimited line.
|
|
7
|
+
* stdin → requests, stdout → responses, stderr → logs.
|
|
8
|
+
*/
|
|
9
|
+
import { getAllTools } from "../tools.js";
|
|
10
|
+
import { McpServer } from "./server.js";
|
|
11
|
+
export async function startMcpServer() {
|
|
12
|
+
const tools = getAllTools();
|
|
13
|
+
const context = { workingDir: process.cwd() };
|
|
14
|
+
const server = new McpServer(tools, context);
|
|
15
|
+
server.start();
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=server-mode.js.map
|
|
@@ -34,9 +34,9 @@ export function createFallbackProvider(primary, fallbacks) {
|
|
|
34
34
|
for (let i = 0; i < providers.length; i++) {
|
|
35
35
|
const p = providers[i];
|
|
36
36
|
try {
|
|
37
|
-
let
|
|
37
|
+
let _hasYielded = false;
|
|
38
38
|
for await (const event of p.provider.stream(messages, systemPrompt, tools, p.model)) {
|
|
39
|
-
|
|
39
|
+
_hasYielded = true;
|
|
40
40
|
yield event;
|
|
41
41
|
}
|
|
42
42
|
_activeFallback = i === 0 ? null : p.provider.name;
|
|
@@ -48,7 +48,6 @@ export function createFallbackProvider(primary, fallbacks) {
|
|
|
48
48
|
throw err;
|
|
49
49
|
// Pre-stream failure on primary: try next provider
|
|
50
50
|
_activeFallback = null;
|
|
51
|
-
continue;
|
|
52
51
|
}
|
|
53
52
|
}
|
|
54
53
|
_activeFallback = null;
|
package/dist/query/index.js
CHANGED
|
@@ -29,6 +29,7 @@ export async function* query(userMessage, config, existingMessages = []) {
|
|
|
29
29
|
systemPrompt: config.systemPrompt,
|
|
30
30
|
permissionMode: config.permissionMode,
|
|
31
31
|
askUserQuestion: config.askUserQuestion,
|
|
32
|
+
gitCommitPerTool: config.gitCommitPerTool,
|
|
32
33
|
};
|
|
33
34
|
const estimateTokens = makeTokenEstimator(config.provider);
|
|
34
35
|
const contextManager = new ContextManager(undefined, config.model);
|
|
@@ -56,14 +57,18 @@ export async function* query(userMessage, config, existingMessages = []) {
|
|
|
56
57
|
fullSystemPrompt += `\n\n# Suggested Skills\nThese skills match your request. Use Skill tool to load them:\n${hints}`;
|
|
57
58
|
}
|
|
58
59
|
}
|
|
59
|
-
catch {
|
|
60
|
+
catch {
|
|
61
|
+
/* skills optional */
|
|
62
|
+
}
|
|
60
63
|
// Track memory version for live injection
|
|
61
64
|
let lastMemoryVer = 0;
|
|
62
65
|
try {
|
|
63
66
|
const { memoryVersion } = await import("../harness/memory.js");
|
|
64
67
|
lastMemoryVer = memoryVersion();
|
|
65
68
|
}
|
|
66
|
-
catch {
|
|
69
|
+
catch {
|
|
70
|
+
/* ignore */
|
|
71
|
+
}
|
|
67
72
|
const state = {
|
|
68
73
|
messages: [...existingMessages, createUserMessage(userMessage)],
|
|
69
74
|
turn: 0,
|
|
@@ -85,20 +90,37 @@ export async function* query(userMessage, config, existingMessages = []) {
|
|
|
85
90
|
return;
|
|
86
91
|
}
|
|
87
92
|
// Context window management
|
|
93
|
+
// ── Context window management with circuit breaker ──
|
|
88
94
|
const contextWindow = getContextWindow(config.model);
|
|
89
95
|
const estimatedTokens = estimateMessagesTokens(state.messages, estimateTokens);
|
|
90
|
-
|
|
96
|
+
const MAX_COMPRESSION_FAILURES = 3;
|
|
97
|
+
if (estimatedTokens > contextWindow * 0.8 && (state.compressionFailures ?? 0) < MAX_COMPRESSION_FAILURES) {
|
|
98
|
+
const tokensBefore = estimatedTokens;
|
|
99
|
+
let strategy = "basic";
|
|
91
100
|
state.messages = compressMessages(state.messages, Math.floor(contextWindow * 0.6));
|
|
92
101
|
const afterBasic = estimateMessagesTokens(state.messages, estimateTokens);
|
|
93
102
|
if (afterBasic > contextWindow * 0.7 && state.messages.length > 4) {
|
|
94
103
|
try {
|
|
95
104
|
state.messages = await summarizeConversation(config.provider, state.messages, config.model, Math.floor(contextWindow * 0.5));
|
|
96
|
-
|
|
105
|
+
strategy = "llm-summarization";
|
|
106
|
+
state.compressionFailures = 0; // Reset on success
|
|
97
107
|
}
|
|
98
108
|
catch {
|
|
99
|
-
|
|
109
|
+
state.compressionFailures = (state.compressionFailures ?? 0) + 1;
|
|
110
|
+
strategy = "basic-only (llm failed)";
|
|
100
111
|
}
|
|
101
112
|
}
|
|
113
|
+
const tokensAfter = estimateMessagesTokens(state.messages, estimateTokens);
|
|
114
|
+
yield {
|
|
115
|
+
type: "error",
|
|
116
|
+
message: `Context compressed (${strategy}): ${tokensBefore} → ${tokensAfter} tokens. Re-read any files you need.`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
else if (estimatedTokens > contextWindow * 0.8) {
|
|
120
|
+
yield {
|
|
121
|
+
type: "error",
|
|
122
|
+
message: "Context compression disabled (3 consecutive failures). Consider starting a new session.",
|
|
123
|
+
};
|
|
102
124
|
}
|
|
103
125
|
// ── Dynamic prompt: refresh memories if changed, inject warnings ──
|
|
104
126
|
try {
|
|
@@ -116,7 +138,9 @@ export async function* query(userMessage, config, existingMessages = []) {
|
|
|
116
138
|
lastMemoryVer = currentVer;
|
|
117
139
|
}
|
|
118
140
|
}
|
|
119
|
-
catch {
|
|
141
|
+
catch {
|
|
142
|
+
/* memory refresh optional */
|
|
143
|
+
}
|
|
120
144
|
let turnPrompt = fullSystemPrompt;
|
|
121
145
|
if (config.maxCost && config.maxCost > 0) {
|
|
122
146
|
const pct = state.totalCost / config.maxCost;
|
package/dist/query/tools.js
CHANGED
|
@@ -120,6 +120,17 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
|
|
|
120
120
|
/* verification should never break tool execution */
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
|
+
// Auto-commit per tool (if enabled and file was modified)
|
|
124
|
+
if (!result.isError && context.gitCommitPerTool && !tool.isReadOnly(parsed.data)) {
|
|
125
|
+
try {
|
|
126
|
+
const { autoCommitAIEdits } = await import("../git/index.js");
|
|
127
|
+
const filePaths = getAffectedFiles(tool.name, parsed.data);
|
|
128
|
+
autoCommitAIEdits(tool.name, filePaths);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
/* auto-commit is optional */
|
|
132
|
+
}
|
|
133
|
+
}
|
|
123
134
|
// Strip ANSI and cap output, then append verification suffix
|
|
124
135
|
let output = result.output.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "") + verificationSuffix;
|
|
125
136
|
if (output.length > MAX_TOOL_RESULT_CHARS) {
|
package/dist/query/types.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ export type QueryConfig = {
|
|
|
18
18
|
abortSignal?: AbortSignal;
|
|
19
19
|
/** Working directory for tool execution (defaults to process.cwd()) */
|
|
20
20
|
workingDir?: string;
|
|
21
|
+
/** Auto-commit after each file-modifying tool */
|
|
22
|
+
gitCommitPerTool?: boolean;
|
|
21
23
|
};
|
|
22
24
|
export type TransitionReason = "next_turn" | "retry_network" | "retry_prompt_too_long" | "retry_max_output_tokens";
|
|
23
25
|
export type QueryLoopState = {
|
|
@@ -29,5 +31,7 @@ export type QueryLoopState = {
|
|
|
29
31
|
consecutiveErrors: number;
|
|
30
32
|
transition?: TransitionReason;
|
|
31
33
|
promptTooLongRetries?: number;
|
|
34
|
+
/** Track consecutive compression failures for circuit breaker */
|
|
35
|
+
compressionFailures?: number;
|
|
32
36
|
};
|
|
33
37
|
//# sourceMappingURL=types.d.ts.map
|
package/dist/repl.js
CHANGED
|
@@ -685,7 +685,9 @@ export async function startREPL(config) {
|
|
|
685
685
|
// LLM-assisted merge: consolidate instead of blind append
|
|
686
686
|
const { createUserMessage: makeMsg } = await import("./types/message.js");
|
|
687
687
|
try {
|
|
688
|
-
const consolidated = await config.provider.complete([
|
|
688
|
+
const consolidated = await config.provider.complete([
|
|
689
|
+
makeMsg(`Merge this user profile with new observations into a single cohesive profile. Keep the most important and recent information. Remove duplicates. Stay under 2000 characters. Return ONLY the merged profile text.\n\nCurrent profile:\n${currentProfile}\n\nNew observations:\n${newObservations}`),
|
|
690
|
+
], "You are a profile curator. Return ONLY the merged profile, no commentary.", undefined, currentModel);
|
|
689
691
|
updateUserProfile(consolidated.content);
|
|
690
692
|
}
|
|
691
693
|
catch {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { discoverSkills, findSkill } from "../../harness/plugins.js";
|
|
@@ -24,7 +24,7 @@ export const SkillTool = {
|
|
|
24
24
|
return { output: "Error: Invalid skill name.", isError: true };
|
|
25
25
|
}
|
|
26
26
|
// Early path traversal check for Level 2
|
|
27
|
-
if (input.path
|
|
27
|
+
if (input.path?.includes("..")) {
|
|
28
28
|
return { output: "Error: Path traversal not allowed.", isError: true };
|
|
29
29
|
}
|
|
30
30
|
// List skills if "list" or "ls"
|
|
@@ -62,6 +62,30 @@ export const SkillTool = {
|
|
|
62
62
|
return { output: `File not found: ${input.path} (looked in ${skillDir}/)`, isError: true };
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
|
+
// Track usage (fire-and-forget, don't block skill invocation)
|
|
66
|
+
try {
|
|
67
|
+
let raw = readFileSync(skill.filePath, "utf-8");
|
|
68
|
+
const now = Date.now();
|
|
69
|
+
const usedMatch = raw.match(/^timesUsed:\s*(\d+)$/m);
|
|
70
|
+
const count = usedMatch ? parseInt(usedMatch[1], 10) + 1 : 1;
|
|
71
|
+
if (usedMatch) {
|
|
72
|
+
raw = raw.replace(/^timesUsed:\s*\d+$/m, `timesUsed: ${count}`);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
const first = raw.indexOf("---");
|
|
76
|
+
const closing = raw.indexOf("---", first + 3);
|
|
77
|
+
if (closing > 0) {
|
|
78
|
+
raw = `${raw.slice(0, closing)}timesUsed: ${count}\nlastUsed: ${now}\n${raw.slice(closing)}`;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (raw.match(/^lastUsed:/m)) {
|
|
82
|
+
raw = raw.replace(/^lastUsed:\s*\d+$/m, `lastUsed: ${now}`);
|
|
83
|
+
}
|
|
84
|
+
writeFileSync(skill.filePath, raw);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
/* don't block on tracking failure */
|
|
88
|
+
}
|
|
65
89
|
return { output: skill.content, isError: false };
|
|
66
90
|
},
|
|
67
91
|
prompt() {
|