@zhijiewang/openharness 2.17.0 → 2.19.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.
@@ -10,6 +10,45 @@ export class OllamaProvider {
10
10
  this.baseUrl = (config.baseUrl ?? "http://localhost:11434").replace(/\/$/, "");
11
11
  this.defaultModel = config.defaultModel ?? "llama3.1";
12
12
  }
13
+ /**
14
+ * Estimate the prompt size and pick a `num_ctx` for Ollama. Without this
15
+ * Ollama defaults to a 2048-token context window — anything bigger gets
16
+ * silently truncated server-side. OH's typical system prompt + tool list
17
+ * already pushes ~4 K, so multi-turn chats lose prior turns and the model
18
+ * appears to "forget" what was just said. See issue #61.
19
+ *
20
+ * Strategy: rough char/4 token estimate, +1 K headroom for the response,
21
+ * then round up to the next power of 2 ≥ 8192. Capped at 32 K to keep KV
22
+ * cache bounded; users with bigger models can override via
23
+ * `OLLAMA_NUM_CTX`.
24
+ */
25
+ computeNumCtx(messages, systemPrompt, tools) {
26
+ const override = process.env.OLLAMA_NUM_CTX;
27
+ if (override) {
28
+ const parsed = Number(override);
29
+ if (Number.isFinite(parsed) && parsed > 0)
30
+ return Math.floor(parsed);
31
+ }
32
+ const estimate = (s) => Math.ceil(s.length / 4);
33
+ let total = systemPrompt ? estimate(systemPrompt) : 0;
34
+ for (const m of messages) {
35
+ total += estimate(m.content);
36
+ if (m.toolCalls)
37
+ for (const tc of m.toolCalls)
38
+ total += estimate(JSON.stringify(tc.arguments));
39
+ if (m.toolResults)
40
+ for (const tr of m.toolResults)
41
+ total += estimate(tr.output);
42
+ }
43
+ if (tools)
44
+ for (const t of tools)
45
+ total += estimate(JSON.stringify(t));
46
+ const padded = Math.ceil(total * 1.25) + 1024;
47
+ let nc = 8192;
48
+ while (nc < padded && nc < 32768)
49
+ nc *= 2;
50
+ return Math.min(nc, 32768);
51
+ }
13
52
  convertMessages(messages, systemPrompt) {
14
53
  const converted = [];
15
54
  if (systemPrompt) {
@@ -69,6 +108,7 @@ export class OllamaProvider {
69
108
  model: m,
70
109
  messages: msgs,
71
110
  stream: true,
111
+ options: { num_ctx: this.computeNumCtx(messages, systemPrompt, tools) },
72
112
  };
73
113
  const ollamaTools = this.convertTools(tools);
74
114
  if (ollamaTools)
@@ -219,6 +259,7 @@ export class OllamaProvider {
219
259
  model: m,
220
260
  messages: msgs,
221
261
  stream: false,
262
+ options: { num_ctx: this.computeNumCtx(messages, systemPrompt, tools) },
222
263
  };
223
264
  const ollamaTools = this.convertTools(tools);
224
265
  if (ollamaTools)
@@ -42,11 +42,12 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
42
42
  // Permission check
43
43
  const perm = checkPermission(permissionMode, tool.riskLevel, tool.isReadOnly(parsed.data), tool.name, parsed.data);
44
44
  if (!perm.allowed) {
45
- if (perm.reason === "needs-approval" && askUser) {
46
- const { formatToolArgs } = await import("../utils/tool-summary.js");
47
- const description = formatToolArgs(tool.name, toolCall.arguments);
48
- // Hook: permissionRequest fires between preToolUse and the interactive askUser prompt.
49
- // Only fires when checkPermission says "needs-approval" AND askUser is provided.
45
+ if (perm.reason === "needs-approval") {
46
+ // Hook: permissionRequest fires whenever checkPermission says
47
+ // "needs-approval", in both interactive and headless modes. Configured
48
+ // hooks get first say; if they return "ask" or have no decision, we
49
+ // fall through to the interactive prompt when one is available, or
50
+ // fail-closed deny in headless mode (issue #62).
50
51
  const hookOutcome = await emitHookWithOutcome("permissionRequest", {
51
52
  toolName: tool.name,
52
53
  toolArgs: JSON.stringify(toolCall.arguments).slice(0, 1000),
@@ -55,19 +56,30 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
55
56
  permissionAction: "ask",
56
57
  });
57
58
  if (hookOutcome.permissionDecision === "allow") {
58
- // Hook granted permission — skip interactive prompt and proceed to execution.
59
+ // Hook granted permission — proceed to execution.
59
60
  }
60
61
  else if (hookOutcome.permissionDecision === "deny" || !hookOutcome.allowed) {
61
62
  const reason = hookOutcome.reason ? `: ${hookOutcome.reason}` : "";
62
63
  return { output: `Permission denied by hook${reason}`, isError: true };
63
64
  }
64
- else {
65
- // "ask" or no decision → fall through to interactive prompt
65
+ else if (askUser) {
66
+ // "ask" or no decision → interactive prompt when available
67
+ const { formatToolArgs } = await import("../utils/tool-summary.js");
68
+ const description = formatToolArgs(tool.name, toolCall.arguments);
66
69
  const allowed = await askUser(tool.name, description, tool.riskLevel);
67
70
  if (!allowed) {
68
71
  return { output: "Permission denied by user.", isError: true };
69
72
  }
70
73
  }
74
+ else {
75
+ // Headless mode with no hook decision and no interactive prompt:
76
+ // fail-closed deny. SDK consumers should configure a permissionRequest
77
+ // hook (or use canUseTool) to make per-call decisions.
78
+ return {
79
+ output: "Permission denied: needs-approval (no interactive prompt available; configure a permissionRequest hook to gate this tool)",
80
+ isError: true,
81
+ };
82
+ }
71
83
  }
72
84
  else {
73
85
  return { output: `Permission denied: ${perm.reason}`, isError: true };
@@ -0,0 +1,23 @@
1
+ import { z } from "zod";
2
+ import type { Tool } from "../../Tool.js";
3
+ declare const inputSchema: z.ZodObject<{
4
+ server: z.ZodOptional<z.ZodString>;
5
+ }, "strip", z.ZodTypeAny, {
6
+ server?: string | undefined;
7
+ }, {
8
+ server?: string | undefined;
9
+ }>;
10
+ export type McpResourceEntry = {
11
+ server: string;
12
+ uri: string;
13
+ name: string;
14
+ description?: string;
15
+ };
16
+ /**
17
+ * Pure formatter — renders the resource list as a markdown table.
18
+ * Exported for testing; production callers should use the tool's `.call()`.
19
+ */
20
+ export declare function formatResourcesList(resources: McpResourceEntry[], serverFilter?: string): string;
21
+ export declare const ListMcpResourcesTool: Tool<typeof inputSchema>;
22
+ export {};
23
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,53 @@
1
+ import { z } from "zod";
2
+ import { listMcpResources } from "../../mcp/loader.js";
3
+ const inputSchema = z.object({
4
+ server: z.string().optional(),
5
+ });
6
+ /**
7
+ * Pure formatter — renders the resource list as a markdown table.
8
+ * Exported for testing; production callers should use the tool's `.call()`.
9
+ */
10
+ export function formatResourcesList(resources, serverFilter) {
11
+ const filtered = serverFilter ? resources.filter((r) => r.server === serverFilter) : resources;
12
+ if (filtered.length === 0) {
13
+ if (serverFilter) {
14
+ return `No MCP resources available from server '${serverFilter}'.`;
15
+ }
16
+ return "No MCP resources available. Connect an MCP server that exposes resources under mcpServers in .oh/config.yaml.";
17
+ }
18
+ const lines = ["| Server | URI | Name | Description |", "|--------|-----|------|-------------|"];
19
+ for (const r of filtered) {
20
+ const desc = (r.description ?? "").replace(/\|/g, "\\|").slice(0, 80);
21
+ const name = r.name.replace(/\|/g, "\\|");
22
+ const uri = r.uri.replace(/\|/g, "\\|");
23
+ lines.push(`| ${r.server} | ${uri} | ${name} | ${desc} |`);
24
+ }
25
+ return lines.join("\n");
26
+ }
27
+ export const ListMcpResourcesTool = {
28
+ name: "ListMcpResources",
29
+ description: "List resources exposed by connected MCP servers.",
30
+ inputSchema,
31
+ riskLevel: "low",
32
+ isReadOnly() {
33
+ return true;
34
+ },
35
+ isConcurrencySafe() {
36
+ return true;
37
+ },
38
+ async call(input) {
39
+ try {
40
+ const resources = await listMcpResources();
41
+ return { output: formatResourcesList(resources, input.server), isError: false };
42
+ }
43
+ catch (err) {
44
+ return { output: `Error listing MCP resources: ${err.message}`, isError: true };
45
+ }
46
+ },
47
+ prompt() {
48
+ return `List resources exposed by connected MCP servers. Parameters:
49
+ - server (string, optional): restrict to this server's resources.
50
+ Returns a markdown table with columns: Server, URI, Name, Description. Use ReadMcpResource with a URI from the table to fetch the content. Resources are read-only data sources (docs, indices, state) — distinct from MCP tools, which are actions.`;
51
+ },
52
+ };
53
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,20 @@
1
+ import { z } from "zod";
2
+ import type { Tool } from "../../Tool.js";
3
+ declare const inputSchema: z.ZodObject<{
4
+ uri: z.ZodString;
5
+ server: z.ZodOptional<z.ZodString>;
6
+ }, "strip", z.ZodTypeAny, {
7
+ uri: string;
8
+ server?: string | undefined;
9
+ }, {
10
+ uri: string;
11
+ server?: string | undefined;
12
+ }>;
13
+ /**
14
+ * Pure helper — truncates resource content to MAX_OUTPUT_CHARS with a
15
+ * trailing `[...truncated]` marker when exceeded. Exported for testing.
16
+ */
17
+ export declare function formatResourceContent(content: string, maxChars?: number): string;
18
+ export declare const ReadMcpResourceTool: Tool<typeof inputSchema>;
19
+ export {};
20
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,51 @@
1
+ import { z } from "zod";
2
+ import { readMcpResource } from "../../mcp/loader.js";
3
+ const inputSchema = z.object({
4
+ uri: z.string(),
5
+ server: z.string().optional(),
6
+ });
7
+ const MAX_OUTPUT_CHARS = 50_000;
8
+ /**
9
+ * Pure helper — truncates resource content to MAX_OUTPUT_CHARS with a
10
+ * trailing `[...truncated]` marker when exceeded. Exported for testing.
11
+ */
12
+ export function formatResourceContent(content, maxChars = MAX_OUTPUT_CHARS) {
13
+ if (content.length <= maxChars)
14
+ return content;
15
+ return `${content.slice(0, maxChars)}\n[...truncated at ${maxChars} chars, original length ${content.length}]`;
16
+ }
17
+ export const ReadMcpResourceTool = {
18
+ name: "ReadMcpResource",
19
+ description: "Read a specific MCP resource by URI from a connected MCP server.",
20
+ inputSchema,
21
+ riskLevel: "low",
22
+ isReadOnly() {
23
+ return true;
24
+ },
25
+ isConcurrencySafe() {
26
+ return true;
27
+ },
28
+ async call(input) {
29
+ try {
30
+ const content = await readMcpResource(input.uri, input.server);
31
+ if (content === null) {
32
+ const where = input.server ? ` from server '${input.server}'` : "";
33
+ return {
34
+ output: `Resource '${input.uri}' not found${where}. Run ListMcpResources to see available URIs.`,
35
+ isError: true,
36
+ };
37
+ }
38
+ return { output: formatResourceContent(content), isError: false };
39
+ }
40
+ catch (err) {
41
+ return { output: `Error reading MCP resource: ${err.message}`, isError: true };
42
+ }
43
+ },
44
+ prompt() {
45
+ return `Read a specific resource from an MCP server by URI. Parameters:
46
+ - uri (string, required): the resource URI, as shown by ListMcpResources.
47
+ - server (string, optional): restrict lookup to this server. When omitted, the first server whose readResource call succeeds is used.
48
+ Output is truncated at ~50KB. For discovery, call ListMcpResources first to get URIs.`;
49
+ },
50
+ };
51
+ //# sourceMappingURL=index.js.map
package/dist/tools.js CHANGED
@@ -23,6 +23,7 @@ import { GlobTool } from "./tools/GlobTool/index.js";
23
23
  import { GrepTool } from "./tools/GrepTool/index.js";
24
24
  import { ImageReadTool } from "./tools/ImageReadTool/index.js";
25
25
  import { KillProcessTool } from "./tools/KillProcessTool/index.js";
26
+ import { ListMcpResourcesTool } from "./tools/ListMcpResourcesTool/index.js";
26
27
  import { LSTool } from "./tools/LSTool/index.js";
27
28
  import { MemoryTool } from "./tools/MemoryTool/index.js";
28
29
  import { MonitorTool } from "./tools/MonitorTool/index.js";
@@ -31,6 +32,7 @@ import { NotebookEditTool } from "./tools/NotebookEditTool/index.js";
31
32
  import { ParallelAgentTool } from "./tools/ParallelAgentTool/index.js";
32
33
  import { PipelineTool } from "./tools/PipelineTool/index.js";
33
34
  import { PowerShellTool } from "./tools/PowerShellTool/index.js";
35
+ import { ReadMcpResourceTool } from "./tools/ReadMcpResourceTool/index.js";
34
36
  import { RemoteTriggerTool } from "./tools/RemoteTriggerTool/index.js";
35
37
  import { ScheduleWakeupTool } from "./tools/ScheduleWakeupTool/index.js";
36
38
  import { SendMessageTool } from "./tools/SendMessageTool/index.js";
@@ -106,6 +108,8 @@ export function getAllTools() {
106
108
  ScheduleWakeupTool,
107
109
  SessionSearchTool,
108
110
  TodoWriteTool,
111
+ ListMcpResourcesTool,
112
+ ReadMcpResourceTool,
109
113
  ];
110
114
  return [...core, ...extended.map((t) => new DeferredTool(t))];
111
115
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Minimal JSON Schema validator — covers the common subset sufficient for
3
+ * constraining LLM output in headless mode. Supported keywords:
4
+ *
5
+ * - `type`: "string" | "number" | "integer" | "boolean" | "object" | "array" | "null"
6
+ * (or an array of those for union types)
7
+ * - `properties`: object → sub-schema per field
8
+ * - `required`: array of field names that must be present
9
+ * - `items`: sub-schema for array elements
10
+ * - `enum`: array of allowed literal values (compared with strict equality)
11
+ *
12
+ * Anything else is silently accepted. This is intentional — we don't want to
13
+ * ship a full JSON Schema engine. For cases that need more (e.g. `pattern`,
14
+ * `oneOf`, `$ref`), use an external validator.
15
+ */
16
+ export type JsonSchema = Record<string, unknown>;
17
+ export type ValidationResult = {
18
+ ok: true;
19
+ } | {
20
+ ok: false;
21
+ errors: string[];
22
+ };
23
+ export declare function validateAgainstJsonSchema(value: unknown, schema: JsonSchema): ValidationResult;
24
+ //# sourceMappingURL=json-schema.d.ts.map
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Minimal JSON Schema validator — covers the common subset sufficient for
3
+ * constraining LLM output in headless mode. Supported keywords:
4
+ *
5
+ * - `type`: "string" | "number" | "integer" | "boolean" | "object" | "array" | "null"
6
+ * (or an array of those for union types)
7
+ * - `properties`: object → sub-schema per field
8
+ * - `required`: array of field names that must be present
9
+ * - `items`: sub-schema for array elements
10
+ * - `enum`: array of allowed literal values (compared with strict equality)
11
+ *
12
+ * Anything else is silently accepted. This is intentional — we don't want to
13
+ * ship a full JSON Schema engine. For cases that need more (e.g. `pattern`,
14
+ * `oneOf`, `$ref`), use an external validator.
15
+ */
16
+ export function validateAgainstJsonSchema(value, schema) {
17
+ const errors = [];
18
+ validate(value, schema, "", errors);
19
+ return errors.length === 0 ? { ok: true } : { ok: false, errors };
20
+ }
21
+ function validate(value, schema, path, errors) {
22
+ if (schema.enum !== undefined && Array.isArray(schema.enum)) {
23
+ if (!schema.enum.some((allowed) => deepEqual(allowed, value))) {
24
+ errors.push(`${prefix(path)}value ${JSON.stringify(value)} is not one of the enum values`);
25
+ return;
26
+ }
27
+ }
28
+ if (schema.type !== undefined) {
29
+ const types = Array.isArray(schema.type) ? schema.type : [schema.type];
30
+ if (!types.some((t) => matchesType(value, t))) {
31
+ errors.push(`${prefix(path)}expected ${types.join(" or ")}, got ${describeActual(value)}`);
32
+ return;
33
+ }
34
+ }
35
+ if (matchesType(value, "object") && schema.properties) {
36
+ const properties = schema.properties;
37
+ const required = schema.required ?? [];
38
+ const obj = value;
39
+ for (const field of required) {
40
+ if (!(field in obj)) {
41
+ const fullPath = path ? `${path}.${field}` : field;
42
+ errors.push(`missing required property '${fullPath}'`);
43
+ }
44
+ }
45
+ for (const [field, subSchema] of Object.entries(properties)) {
46
+ if (field in obj) {
47
+ validate(obj[field], subSchema, path ? `${path}.${field}` : field, errors);
48
+ }
49
+ }
50
+ }
51
+ if (matchesType(value, "array") && schema.items) {
52
+ const items = schema.items;
53
+ const arr = value;
54
+ arr.forEach((item, i) => {
55
+ validate(item, items, `${path}[${i}]`, errors);
56
+ });
57
+ }
58
+ }
59
+ function matchesType(value, type) {
60
+ switch (type) {
61
+ case "string":
62
+ return typeof value === "string";
63
+ case "number":
64
+ return typeof value === "number" && Number.isFinite(value);
65
+ case "integer":
66
+ return typeof value === "number" && Number.isInteger(value);
67
+ case "boolean":
68
+ return typeof value === "boolean";
69
+ case "null":
70
+ return value === null;
71
+ case "array":
72
+ return Array.isArray(value);
73
+ case "object":
74
+ return typeof value === "object" && value !== null && !Array.isArray(value);
75
+ default:
76
+ return false;
77
+ }
78
+ }
79
+ function describeActual(value) {
80
+ if (value === null)
81
+ return "null";
82
+ if (Array.isArray(value))
83
+ return "array";
84
+ return typeof value;
85
+ }
86
+ function prefix(path) {
87
+ return path ? `${path}: ` : "";
88
+ }
89
+ function deepEqual(a, b) {
90
+ if (a === b)
91
+ return true;
92
+ if (typeof a !== typeof b)
93
+ return false;
94
+ if (a === null || b === null)
95
+ return a === b;
96
+ if (Array.isArray(a) && Array.isArray(b)) {
97
+ if (a.length !== b.length)
98
+ return false;
99
+ return a.every((x, i) => deepEqual(x, b[i]));
100
+ }
101
+ if (typeof a === "object" && typeof b === "object") {
102
+ const ka = Object.keys(a);
103
+ const kb = Object.keys(b);
104
+ if (ka.length !== kb.length)
105
+ return false;
106
+ return ka.every((k) => deepEqual(a[k], b[k]));
107
+ }
108
+ return false;
109
+ }
110
+ //# sourceMappingURL=json-schema.js.map
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Parse the `--max-budget-usd` CLI argument into a positive USD amount.
3
+ *
4
+ * Accepts plain decimals (`5`, `0.50`, `2.5`) and an optional leading `$`.
5
+ * Negative or zero values are rejected — a budget of zero would block the
6
+ * very first call before any cost has accumulated.
7
+ *
8
+ * Returns `{ ok: true, value }` on success or `{ ok: false, message }` on
9
+ * invalid input. The CLI wrapper translates failures into a stderr message
10
+ * and exit code 2.
11
+ */
12
+ export type ParseBudgetResult = {
13
+ ok: true;
14
+ value: number;
15
+ } | {
16
+ ok: false;
17
+ message: string;
18
+ };
19
+ export declare function parseMaxBudgetUsd(raw: string): ParseBudgetResult;
20
+ //# sourceMappingURL=parse-budget.d.ts.map
@@ -0,0 +1,12 @@
1
+ export function parseMaxBudgetUsd(raw) {
2
+ const cleaned = raw.replace(/^\$/, "").trim();
3
+ if (cleaned === "") {
4
+ return { ok: false, message: `--max-budget-usd must be a positive USD amount, got '${raw}'` };
5
+ }
6
+ const n = Number(cleaned);
7
+ if (!Number.isFinite(n) || n <= 0) {
8
+ return { ok: false, message: `--max-budget-usd must be a positive USD amount, got '${raw}'` };
9
+ }
10
+ return { ok: true, value: n };
11
+ }
12
+ //# sourceMappingURL=parse-budget.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhijiewang/openharness",
3
- "version": "2.17.0",
3
+ "version": "2.19.0",
4
4
  "description": "Open-source terminal coding agent. Works with any LLM.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,17 +22,23 @@
22
22
  "README.md",
23
23
  "LICENSE"
24
24
  ],
25
+ "workspaces": [
26
+ "packages/sdk"
27
+ ],
25
28
  "scripts": {
26
29
  "dev": "tsx src/main.tsx",
27
30
  "build": "tsc",
31
+ "build:sdk": "npm --workspace @zhijiewang/openharness-sdk run build",
28
32
  "prepare": "husky",
29
33
  "prepublishOnly": "npm run build",
30
- "test": "node scripts/test.mjs",
34
+ "test": "node scripts/test.mjs && npm --workspace @zhijiewang/openharness-sdk run test",
35
+ "test:cli": "node scripts/test.mjs",
36
+ "test:sdk": "npm --workspace @zhijiewang/openharness-sdk run test",
31
37
  "test:coverage": "node scripts/coverage.mjs",
32
- "typecheck": "tsc --noEmit",
33
- "lint": "biome check src/",
34
- "lint:fix": "biome check --write src/",
35
- "format": "biome format --write src/",
38
+ "typecheck": "tsc --noEmit && npm --workspace @zhijiewang/openharness-sdk run typecheck",
39
+ "lint": "biome check src/ packages/sdk/src/",
40
+ "lint:fix": "biome check --write src/ packages/sdk/src/",
41
+ "format": "biome format --write src/ packages/sdk/src/",
36
42
  "start": "node dist/main.js"
37
43
  },
38
44
  "dependencies": {