@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 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;
@@ -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 [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: () => {
@@ -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) */
@@ -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
- 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 };
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;
@@ -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
@@ -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 hasYielded = false;
37
+ let _hasYielded = false;
38
38
  for await (const event of p.provider.stream(messages, systemPrompt, tools, p.model)) {
39
- hasYielded = true;
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;
@@ -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 { /* skills optional */ }
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 { /* ignore */ }
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
- 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";
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
- yield { type: "error", message: "Context compressed with LLM summarization." };
105
+ strategy = "llm-summarization";
106
+ state.compressionFailures = 0; // Reset on success
97
107
  }
98
108
  catch {
99
- /* continue with basic compression */
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 { /* memory refresh optional */ }
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;
@@ -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.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "Open-source terminal coding agent. Works with any LLM.",
5
5
  "type": "module",
6
6
  "bin": {