@zhijiewang/openharness 2.3.1 → 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 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
- [![npm version](https://img.shields.io/npm/v/@zhijiewang/openharness)](https://www.npmjs.com/package/@zhijiewang/openharness) [![npm downloads](https://img.shields.io/npm/dm/@zhijiewang/openharness)](https://www.npmjs.com/package/@zhijiewang/openharness) [![license](https://img.shields.io/npm/l/@zhijiewang/openharness)](LICENSE) ![tests](https://img.shields.io/badge/tests-769-brightgreen) ![tools](https://img.shields.io/badge/tools-37-blue) ![Node.js 18+](https://img.shields.io/badge/node-18%2B-green) ![TypeScript](https://img.shields.io/badge/typescript-strict-blue) [![GitHub stars](https://img.shields.io/github/stars/zhijiewong/openharness)](https://github.com/zhijiewong/openharness) [![GitHub issues](https://img.shields.io/github/issues-raw/zhijiewong/openharness)](https://github.com/zhijiewong/openharness/issues) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen)](https://github.com/zhijiewong/openharness/pulls)
24
+ [![npm version](https://img.shields.io/npm/v/@zhijiewang/openharness)](https://www.npmjs.com/package/@zhijiewang/openharness) [![npm downloads](https://img.shields.io/npm/dm/@zhijiewang/openharness)](https://www.npmjs.com/package/@zhijiewang/openharness) [![license](https://img.shields.io/npm/l/@zhijiewang/openharness)](LICENSE) ![tests](https://img.shields.io/badge/tests-784-brightgreen) ![tools](https://img.shields.io/badge/tools-41-blue) ![Node.js 18+](https://img.shields.io/badge/node-18%2B-green) ![TypeScript](https://img.shields.io/badge/typescript-strict-blue) [![GitHub stars](https://img.shields.io/github/stars/zhijiewong/openharness)](https://github.com/zhijiewong/openharness) [![GitHub issues](https://img.shields.io/github/issues-raw/zhijiewong/openharness)](https://github.com/zhijiewong/openharness/issues) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen)](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 | 37 with permission gates | 43+ | File-focused | 20+ |
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;
@@ -993,6 +993,117 @@ register("rebuild-sessions", "Rebuild session search index", () => {
993
993
  });
994
994
  return { output: "Rebuilding session search index...", handled: true };
995
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
+ });
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
+ });
996
1107
  // ── Command Parser ──
997
1108
  /**
998
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 [hatchGotchi, setHatchGotchi] = useState(false);
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
- }, [step, providerIdx, provider, modelIdx, availableModels, model]));
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: () => {
@@ -68,6 +68,15 @@ export type OhConfig = {
68
68
  balanced?: string;
69
69
  powerful?: string;
70
70
  };
71
+ /** Fallback providers — tried in order when primary fails */
72
+ fallbackProviders?: Array<{
73
+ provider: string;
74
+ model?: string;
75
+ apiKey?: string;
76
+ baseUrl?: string;
77
+ }>;
78
+ /** Auto-commit after each file-modifying tool execution */
79
+ gitCommitPerTool?: boolean;
71
80
  /** Effort level for LLM reasoning depth */
72
81
  effortLevel?: "low" | "medium" | "high" | "max";
73
82
  /** Opt-in telemetry (default: off) */
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import type { Provider } from "../providers/base.js";
11
11
  import type { Message } from "../types/message.js";
12
+ export declare function memoryVersion(): number;
12
13
  /**
13
14
  * Memory types — supports both legacy and Claude Code-compatible names.
14
15
  * Legacy: convention, preference, project, debugging
@@ -28,7 +29,7 @@ export type MemoryEntry = {
28
29
  };
29
30
  /** Load all memories from project and global dirs */
30
31
  export declare function loadMemories(): MemoryEntry[];
31
- /** Build a system prompt section from loaded memories */
32
+ /** Build a system prompt section from loaded memories (capped at MEMORY_PROMPT_MAX_CHARS) */
32
33
  export declare function memoriesToPrompt(memories: MemoryEntry[]): string;
33
34
  /** Save a memory entry to the project memory directory */
34
35
  export declare function saveMemory(name: string, type: MemoryType, description: string, content: string, global?: boolean): string;
@@ -13,6 +13,11 @@ import { join, resolve, sep } from "node:path";
13
13
  import { createUserMessage } from "../types/message.js";
14
14
  const PROJECT_MEMORY_DIR = join(".oh", "memory");
15
15
  const GLOBAL_MEMORY_DIR = join(homedir(), ".oh", "memory");
16
+ // Version counter — incremented on every save, used by query loop for live injection
17
+ let _memoryVersion = 0;
18
+ export function memoryVersion() {
19
+ return _memoryVersion;
20
+ }
16
21
  /** Load all memories from project and global dirs */
17
22
  export function loadMemories() {
18
23
  const entries = [];
@@ -60,12 +65,19 @@ function parseMemory(raw, filePath) {
60
65
  accessCount: accessCountMatch ? parseInt(accessCountMatch[1], 10) : 0,
61
66
  };
62
67
  }
63
- /** Build a system prompt section from loaded memories */
68
+ /** Build a system prompt section from loaded memories (capped at MEMORY_PROMPT_MAX_CHARS) */
64
69
  export function memoriesToPrompt(memories) {
65
70
  if (memories.length === 0)
66
71
  return "";
67
- const lines = memories.map((m) => `- **${m.name}** (${m.type}): ${m.content.slice(0, 200)}`);
68
- return `# Remembered Context\n${lines.join("\n")}`;
72
+ const header = "# Remembered Context\n";
73
+ let result = header;
74
+ for (const m of memories) {
75
+ const line = `- **${m.name}** (${m.type}): ${m.content.slice(0, 200)}\n`;
76
+ if (result.length + line.length > MEMORY_PROMPT_MAX_CHARS)
77
+ break;
78
+ result += line;
79
+ }
80
+ return result.trimEnd();
69
81
  }
70
82
  /** Save a memory entry to the project memory directory */
71
83
  export function saveMemory(name, type, description, content, global = false) {
@@ -90,6 +102,7 @@ accessCount: 0
90
102
  ${content}
91
103
  `;
92
104
  writeFileSync(filePath, md);
105
+ _memoryVersion++;
93
106
  updateMemoryIndex(dir);
94
107
  return filePath;
95
108
  }
@@ -270,11 +283,39 @@ export function consolidateMemories() {
270
283
  // Refresh MEMORY.md index after pruning
271
284
  updateMemoryIndex(PROJECT_MEMORY_DIR);
272
285
  updateMemoryIndex(GLOBAL_MEMORY_DIR);
273
- return { total: all.length, pruned: prunedCount, decayed: decayedCount };
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 };
274
314
  }
275
315
  // ── User Profile ──
276
316
  const USER_PROFILE_FILE = "USER.md";
277
- const USER_PROFILE_MAX_CHARS = 2000;
317
+ const USER_PROFILE_MAX_CHARS = 1375; // Matches Hermes USER.md limit
318
+ const MEMORY_PROMPT_MAX_CHARS = 2200; // Matches Hermes MEMORY.md limit
278
319
  /** Load the user profile from .oh/memory/USER.md */
279
320
  export function loadUserProfile() {
280
321
  const filePath = join(PROJECT_MEMORY_DIR, USER_PROFILE_FILE);
@@ -307,6 +348,7 @@ updatedAt: ${Date.now()}
307
348
  ${truncated}
308
349
  `;
309
350
  writeFileSync(join(PROJECT_MEMORY_DIR, USER_PROFILE_FILE), md);
351
+ _memoryVersion++;
310
352
  }
311
353
  /** Format user profile for system prompt injection */
312
354
  export function userProfileToPrompt() {
@@ -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;
@@ -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 { basename, join } from "node:path";
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
- /** Load skills from a directory */
43
- function loadSkillsFromDir(dir, source) {
52
+ /** Recursively collect all .md files from a directory tree */
53
+ function walkMdFiles(dir) {
44
54
  if (!existsSync(dir))
45
55
  return [];
46
- return readdirSync(dir)
47
- .filter((f) => f.endsWith(".md"))
48
- .map((f) => {
49
- const filePath = join(dir, f);
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 || basename(f, ".md"),
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
- if (skills.length === 0)
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 = skills.map((s) => `- ${s.name}: ${s.description}${s.trigger ? ` (auto-trigger: "${s.trigger}")` : ""}`);
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
@@ -158,7 +158,9 @@ export function closeGlobalSessionDb() {
158
158
  try {
159
159
  _singletonDb.close();
160
160
  }
161
- catch { /* ignore */ }
161
+ catch {
162
+ /* ignore */
163
+ }
162
164
  _singletonDb = null;
163
165
  }
164
166
  }
@@ -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
@@ -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")
@@ -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) + "\n");
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
@@ -0,0 +1,27 @@
1
+ /**
2
+ * FallbackProvider — wraps a primary provider with fallback chain.
3
+ *
4
+ * When the primary provider fails (rate limit, 5xx, network), transparently
5
+ * tries the next provider in the chain. Matches Hermes Agent pattern.
6
+ *
7
+ * Design notes:
8
+ * - Streaming fallback only activates if primary fails BEFORE yielding events.
9
+ * Once events are streaming, partial output can't be un-sent, so we don't
10
+ * catch mid-stream errors (they propagate to the caller for retry).
11
+ * - 401/403 are NOT retriable (they're permanent auth failures). Different
12
+ * providers have different keys, so this is handled at the config level.
13
+ */
14
+ import type { Provider } from "./base.js";
15
+ export type FallbackConfig = {
16
+ provider: Provider;
17
+ model?: string;
18
+ };
19
+ /**
20
+ * Create a provider that falls back to alternatives on failure.
21
+ * The primary provider is tried first. If it fails with a retriable error
22
+ * BEFORE streaming begins, each fallback is tried in order.
23
+ */
24
+ export declare function createFallbackProvider(primary: Provider, fallbacks: FallbackConfig[]): Provider & {
25
+ readonly activeFallback: string | null;
26
+ };
27
+ //# sourceMappingURL=fallback.d.ts.map
@@ -0,0 +1,114 @@
1
+ /**
2
+ * FallbackProvider — wraps a primary provider with fallback chain.
3
+ *
4
+ * When the primary provider fails (rate limit, 5xx, network), transparently
5
+ * tries the next provider in the chain. Matches Hermes Agent pattern.
6
+ *
7
+ * Design notes:
8
+ * - Streaming fallback only activates if primary fails BEFORE yielding events.
9
+ * Once events are streaming, partial output can't be un-sent, so we don't
10
+ * catch mid-stream errors (they propagate to the caller for retry).
11
+ * - 401/403 are NOT retriable (they're permanent auth failures). Different
12
+ * providers have different keys, so this is handled at the config level.
13
+ */
14
+ /**
15
+ * Create a provider that falls back to alternatives on failure.
16
+ * The primary provider is tried first. If it fails with a retriable error
17
+ * BEFORE streaming begins, each fallback is tried in order.
18
+ */
19
+ export function createFallbackProvider(primary, fallbacks) {
20
+ let _activeFallback = null;
21
+ const obj = {
22
+ name: primary.name,
23
+ get activeFallback() {
24
+ return _activeFallback;
25
+ },
26
+ async *stream(messages, systemPrompt, tools, model) {
27
+ // Collect first event to detect early failure vs mid-stream failure.
28
+ // If the provider fails before ANY event, try fallback.
29
+ // If it fails mid-stream, propagate the error (partial output already sent).
30
+ const providers = [
31
+ { provider: primary, model },
32
+ ...fallbacks.map((fb) => ({ provider: fb.provider, model: fb.model ?? model })),
33
+ ];
34
+ for (let i = 0; i < providers.length; i++) {
35
+ const p = providers[i];
36
+ try {
37
+ let _hasYielded = false;
38
+ for await (const event of p.provider.stream(messages, systemPrompt, tools, p.model)) {
39
+ _hasYielded = true;
40
+ yield event;
41
+ }
42
+ _activeFallback = i === 0 ? null : p.provider.name;
43
+ return;
44
+ }
45
+ catch (err) {
46
+ // Mid-stream failure: can't un-send events, propagate error
47
+ if (i > 0 || !isRetriableError(err))
48
+ throw err;
49
+ // Pre-stream failure on primary: try next provider
50
+ _activeFallback = null;
51
+ }
52
+ }
53
+ _activeFallback = null;
54
+ throw new Error("All providers failed (primary + fallbacks)");
55
+ },
56
+ async complete(messages, systemPrompt, tools, model) {
57
+ // complete() is atomic — safe to retry with any provider
58
+ const providers = [
59
+ { provider: primary, model },
60
+ ...fallbacks.map((fb) => ({ provider: fb.provider, model: fb.model ?? model })),
61
+ ];
62
+ for (let i = 0; i < providers.length; i++) {
63
+ const p = providers[i];
64
+ try {
65
+ const result = await p.provider.complete(messages, systemPrompt, tools, p.model);
66
+ _activeFallback = i === 0 ? null : p.provider.name;
67
+ return result;
68
+ }
69
+ catch (err) {
70
+ if (!isRetriableError(err))
71
+ throw err;
72
+ }
73
+ }
74
+ _activeFallback = null;
75
+ throw new Error("All providers failed (primary + fallbacks)");
76
+ },
77
+ listModels() {
78
+ return primary.listModels();
79
+ },
80
+ async healthCheck() {
81
+ if (await primary.healthCheck())
82
+ return true;
83
+ for (const fb of fallbacks) {
84
+ if (await fb.provider.healthCheck())
85
+ return true;
86
+ }
87
+ return false;
88
+ },
89
+ estimateTokens: primary.estimateTokens?.bind(primary),
90
+ getModelInfo: primary.getModelInfo?.bind(primary),
91
+ };
92
+ return obj;
93
+ }
94
+ /** Check if an error is worth retrying with a different provider */
95
+ function isRetriableError(err) {
96
+ if (!(err instanceof Error))
97
+ return false;
98
+ const msg = err.message.toLowerCase();
99
+ return (msg.includes("rate limit") ||
100
+ msg.includes("429") ||
101
+ msg.includes("too many requests") ||
102
+ msg.includes("overloaded") ||
103
+ msg.includes("503") ||
104
+ msg.includes("529") ||
105
+ msg.includes("service unavailable") ||
106
+ msg.includes("econnrefused") ||
107
+ msg.includes("network") ||
108
+ msg.includes("timeout")
109
+ // Note: 401/403 are NOT retriable — they're permanent auth failures.
110
+ // Different providers use different API keys, so auth issues don't
111
+ // benefit from fallback. The user should fix their API key.
112
+ );
113
+ }
114
+ //# sourceMappingURL=fallback.js.map
@@ -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,7 +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 { /* skills optional */ }
60
+ catch {
61
+ /* skills optional */
62
+ }
63
+ // Track memory version for live injection
64
+ let lastMemoryVer = 0;
65
+ try {
66
+ const { memoryVersion } = await import("../harness/memory.js");
67
+ lastMemoryVer = memoryVersion();
68
+ }
69
+ catch {
70
+ /* ignore */
71
+ }
60
72
  const state = {
61
73
  messages: [...existingMessages, createUserMessage(userMessage)],
62
74
  turn: 0,
@@ -78,20 +90,69 @@ export async function* query(userMessage, config, existingMessages = []) {
78
90
  return;
79
91
  }
80
92
  // Context window management
93
+ // ── Context window management with circuit breaker ──
81
94
  const contextWindow = getContextWindow(config.model);
82
95
  const estimatedTokens = estimateMessagesTokens(state.messages, estimateTokens);
83
- if (estimatedTokens > contextWindow * 0.8) {
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";
84
100
  state.messages = compressMessages(state.messages, Math.floor(contextWindow * 0.6));
85
101
  const afterBasic = estimateMessagesTokens(state.messages, estimateTokens);
86
102
  if (afterBasic > contextWindow * 0.7 && state.messages.length > 4) {
87
103
  try {
88
104
  state.messages = await summarizeConversation(config.provider, state.messages, config.model, Math.floor(contextWindow * 0.5));
89
- yield { type: "error", message: "Context compressed with LLM summarization." };
105
+ strategy = "llm-summarization";
106
+ state.compressionFailures = 0; // Reset on success
90
107
  }
91
108
  catch {
92
- /* continue with basic compression */
109
+ state.compressionFailures = (state.compressionFailures ?? 0) + 1;
110
+ strategy = "basic-only (llm failed)";
93
111
  }
94
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
+ };
124
+ }
125
+ // ── Dynamic prompt: refresh memories if changed, inject warnings ──
126
+ try {
127
+ const { memoryVersion, loadActiveMemories, memoriesToPrompt } = await import("../harness/memory.js");
128
+ const currentVer = memoryVersion();
129
+ if (currentVer > lastMemoryVer) {
130
+ const fresh = memoriesToPrompt(loadActiveMemories());
131
+ // Replace or append memory section in fullSystemPrompt
132
+ if (fullSystemPrompt.includes("# Remembered Context")) {
133
+ fullSystemPrompt = fullSystemPrompt.replace(/# Remembered Context[\s\S]*?(?=\n# |$)/, fresh);
134
+ }
135
+ else if (fresh) {
136
+ fullSystemPrompt += `\n\n${fresh}`;
137
+ }
138
+ lastMemoryVer = currentVer;
139
+ }
140
+ }
141
+ catch {
142
+ /* memory refresh optional */
143
+ }
144
+ let turnPrompt = fullSystemPrompt;
145
+ if (config.maxCost && config.maxCost > 0) {
146
+ const pct = state.totalCost / config.maxCost;
147
+ if (pct >= 0.9) {
148
+ turnPrompt += `\n\n⚠️ BUDGET CRITICAL: Only $${(config.maxCost - state.totalCost).toFixed(4)} remaining. Provide final response NOW.`;
149
+ }
150
+ else if (pct >= 0.7) {
151
+ turnPrompt += `\n\n⚠️ BUDGET WARNING: ${Math.round((1 - pct) * 100)}% budget remaining. Start consolidating.`;
152
+ }
153
+ }
154
+ if (state.turn >= maxTurns * 0.9 && maxTurns > 1) {
155
+ turnPrompt += `\n\n⚠️ TURN LIMIT: ${maxTurns - state.turn} turn(s) remaining. Wrap up.`;
95
156
  }
96
157
  // ── LLM call with streaming ──
97
158
  let assistantContent = "";
@@ -99,7 +160,7 @@ export async function* query(userMessage, config, existingMessages = []) {
99
160
  let streamError = null;
100
161
  const streamingExecutor = new StreamingToolExecutor(config.tools, toolContext, config.permissionMode, config.askUser, config.abortSignal);
101
162
  try {
102
- for await (const event of config.provider.stream(state.messages, fullSystemPrompt, apiTools, config.model)) {
163
+ for await (const event of config.provider.stream(state.messages, turnPrompt, apiTools, config.model)) {
103
164
  if (config.abortSignal?.aborted)
104
165
  break;
105
166
  switch (event.type) {
@@ -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) {
@@ -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([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}`)], "You are a profile curator. Return ONLY the merged profile, no commentary.", undefined, currentModel);
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 {
@@ -96,6 +96,8 @@ source: auto
96
96
  extractedFrom: ${sessionId}
97
97
  extractedAt: ${now}
98
98
  version: ${version}
99
+ timesUsed: 0
100
+ lastUsed: 0
99
101
  ---
100
102
 
101
103
  ## Procedure
@@ -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 && input.path.includes("..")) {
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() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhijiewang/openharness",
3
- "version": "2.3.1",
3
+ "version": "2.5.0",
4
4
  "description": "Open-source terminal coding agent. Works with any LLM.",
5
5
  "type": "module",
6
6
  "bin": {