@zhijiewang/openharness 1.3.0 → 2.0.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/dist/commands/index.js +45 -6
- package/dist/harness/config.d.ts +12 -1
- package/dist/harness/config.js +5 -0
- package/dist/harness/hooks.d.ts +19 -4
- package/dist/harness/hooks.js +82 -23
- package/dist/harness/rules.js +32 -4
- package/dist/harness/submit-handler.js +18 -2
- package/dist/harness/traces.d.ts +58 -0
- package/dist/harness/traces.js +178 -0
- package/dist/main.js +1 -0
- package/dist/query/compress.js +5 -1
- package/dist/query/context-manager.d.ts +56 -0
- package/dist/query/context-manager.js +111 -0
- package/dist/query/index.js +5 -1
- package/dist/query/tools.js +7 -0
- package/dist/sdk/index.d.ts +75 -0
- package/dist/sdk/index.js +135 -0
- package/dist/services/EvaluatorLoop.d.ts +61 -0
- package/dist/services/EvaluatorLoop.js +157 -0
- package/dist/services/MetaHarness.d.ts +61 -0
- package/dist/services/MetaHarness.js +210 -0
- package/dist/tools/AgentTool/index.js +13 -2
- package/dist/tools/DiagnosticsTool/index.d.ts +3 -3
- package/dist/tools/DiagnosticsTool/index.js +37 -8
- package/dist/tools/MonitorTool/index.d.ts +21 -0
- package/dist/tools/MonitorTool/index.js +114 -0
- package/dist/tools/PowerShellTool/index.d.ts +15 -0
- package/dist/tools/PowerShellTool/index.js +32 -0
- package/dist/tools.js +4 -0
- package/dist/types/permissions.js +42 -2
- package/package.json +6 -2
package/dist/commands/index.js
CHANGED
|
@@ -100,15 +100,54 @@ register("undo", "Undo last AI commit", () => {
|
|
|
100
100
|
handled: true,
|
|
101
101
|
};
|
|
102
102
|
});
|
|
103
|
-
register("rewind", "Restore files from
|
|
104
|
-
const { rewindLastCheckpoint, checkpointCount } = require("../harness/checkpoints.js");
|
|
105
|
-
const
|
|
106
|
-
if (
|
|
103
|
+
register("rewind", "Restore files from checkpoint (interactive picker or last)", (args) => {
|
|
104
|
+
const { rewindLastCheckpoint, listCheckpoints, checkpointCount } = require("../harness/checkpoints.js");
|
|
105
|
+
const checkpoints = listCheckpoints();
|
|
106
|
+
if (checkpoints.length === 0) {
|
|
107
107
|
return { output: "No checkpoints available. Checkpoints are created before file modifications.", handled: true };
|
|
108
108
|
}
|
|
109
|
-
const
|
|
109
|
+
const idx = args.trim();
|
|
110
|
+
// /rewind (no args) — show checkpoint list
|
|
111
|
+
if (!idx) {
|
|
112
|
+
const lines = [`Checkpoints (${checkpoints.length}):\n`];
|
|
113
|
+
for (let i = checkpoints.length - 1; i >= 0; i--) {
|
|
114
|
+
const cp = checkpoints[i];
|
|
115
|
+
const age = Math.round((Date.now() - cp.timestamp) / 60_000);
|
|
116
|
+
lines.push(` ${i + 1}. [${age}m ago] ${cp.description}`);
|
|
117
|
+
lines.push(` Files: ${cp.files.join(', ')}`);
|
|
118
|
+
}
|
|
119
|
+
lines.push('');
|
|
120
|
+
lines.push('Usage: /rewind <number> to restore a specific checkpoint');
|
|
121
|
+
lines.push(' /rewind last to restore the most recent');
|
|
122
|
+
return { output: lines.join('\n'), handled: true };
|
|
123
|
+
}
|
|
124
|
+
// /rewind last — restore most recent
|
|
125
|
+
if (idx === 'last') {
|
|
126
|
+
const cp = rewindLastCheckpoint();
|
|
127
|
+
if (!cp)
|
|
128
|
+
return { output: "No checkpoints.", handled: true };
|
|
129
|
+
return {
|
|
130
|
+
output: `Rewound: ${cp.description}\nRestored ${cp.files.length} file(s): ${cp.files.join(", ")}\n${checkpointCount()} checkpoint(s) remaining.`,
|
|
131
|
+
handled: true,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
// /rewind <n> — restore specific checkpoint
|
|
135
|
+
const num = parseInt(idx, 10);
|
|
136
|
+
if (isNaN(num) || num < 1 || num > checkpoints.length) {
|
|
137
|
+
return { output: `Invalid checkpoint number. Use 1-${checkpoints.length}.`, handled: true };
|
|
138
|
+
}
|
|
139
|
+
// Rewind to specific checkpoint (restore all from that point)
|
|
140
|
+
let restored = 0;
|
|
141
|
+
while (checkpointCount() >= num) {
|
|
142
|
+
const cp = rewindLastCheckpoint();
|
|
143
|
+
if (!cp)
|
|
144
|
+
break;
|
|
145
|
+
restored++;
|
|
146
|
+
if (checkpointCount() < num)
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
110
149
|
return {
|
|
111
|
-
output: `Rewound
|
|
150
|
+
output: `Rewound ${restored} checkpoint(s) to point #${num}.\n${checkpointCount()} checkpoint(s) remaining.`,
|
|
112
151
|
handled: true,
|
|
113
152
|
};
|
|
114
153
|
});
|
package/dist/harness/config.d.ts
CHANGED
|
@@ -11,14 +11,25 @@ export type McpServerConfig = {
|
|
|
11
11
|
timeout?: number;
|
|
12
12
|
};
|
|
13
13
|
export type HookDef = {
|
|
14
|
-
command
|
|
14
|
+
command?: string;
|
|
15
|
+
http?: string;
|
|
16
|
+
prompt?: string;
|
|
15
17
|
match?: string;
|
|
18
|
+
timeout?: number;
|
|
16
19
|
};
|
|
17
20
|
export type HooksConfig = {
|
|
18
21
|
sessionStart?: HookDef[];
|
|
19
22
|
sessionEnd?: HookDef[];
|
|
20
23
|
preToolUse?: HookDef[];
|
|
21
24
|
postToolUse?: HookDef[];
|
|
25
|
+
fileChanged?: HookDef[];
|
|
26
|
+
cwdChanged?: HookDef[];
|
|
27
|
+
subagentStart?: HookDef[];
|
|
28
|
+
subagentStop?: HookDef[];
|
|
29
|
+
preCompact?: HookDef[];
|
|
30
|
+
postCompact?: HookDef[];
|
|
31
|
+
configChange?: HookDef[];
|
|
32
|
+
notification?: HookDef[];
|
|
22
33
|
};
|
|
23
34
|
export type ToolPermissionRule = {
|
|
24
35
|
tool: string;
|
package/dist/harness/config.js
CHANGED
|
@@ -77,6 +77,11 @@ export function readOhConfig(root) {
|
|
|
77
77
|
}
|
|
78
78
|
export function writeOhConfig(cfg, root) {
|
|
79
79
|
invalidateConfigCache();
|
|
80
|
+
// Emit configChange hook (lazy import to avoid circular dependency)
|
|
81
|
+
try {
|
|
82
|
+
require('./hooks.js').emitHook('configChange', {});
|
|
83
|
+
}
|
|
84
|
+
catch { /* ignore */ }
|
|
80
85
|
const p = configPath(root);
|
|
81
86
|
mkdirSync(join(root ?? ".", ".oh"), { recursive: true });
|
|
82
87
|
if (cfg.provider === "llamacpp" || cfg.provider === "lmstudio") {
|
package/dist/harness/hooks.d.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Hooks system — run
|
|
2
|
+
* Hooks system — run commands, HTTP requests, or LLM prompts on lifecycle events.
|
|
3
3
|
*
|
|
4
|
-
* preToolUse hooks can block tool execution (exit code 1
|
|
4
|
+
* preToolUse hooks can block tool execution (exit code 1 / allowed: false).
|
|
5
5
|
* All other hooks are fire-and-forget (errors are silently ignored).
|
|
6
|
+
*
|
|
7
|
+
* Hook types:
|
|
8
|
+
* - command: shell script (existing)
|
|
9
|
+
* - http: POST JSON to URL, expect { allowed: true/false }
|
|
10
|
+
* - prompt: LLM yes/no check via provider.complete()
|
|
6
11
|
*/
|
|
7
|
-
export type HookEvent = "sessionStart" | "sessionEnd" | "preToolUse" | "postToolUse";
|
|
12
|
+
export type HookEvent = "sessionStart" | "sessionEnd" | "preToolUse" | "postToolUse" | "fileChanged" | "cwdChanged" | "subagentStart" | "subagentStop" | "preCompact" | "postCompact" | "configChange" | "notification";
|
|
8
13
|
export type HookContext = {
|
|
9
14
|
toolName?: string;
|
|
10
15
|
toolArgs?: string;
|
|
@@ -16,7 +21,17 @@ export type HookContext = {
|
|
|
16
21
|
permissionMode?: string;
|
|
17
22
|
cost?: string;
|
|
18
23
|
tokens?: string;
|
|
24
|
+
/** For fileChanged: the file path that changed */
|
|
25
|
+
filePath?: string;
|
|
26
|
+
/** For cwdChanged: the new working directory */
|
|
27
|
+
newCwd?: string;
|
|
28
|
+
/** For subagentStart/Stop: the agent ID */
|
|
29
|
+
agentId?: string;
|
|
30
|
+
/** For notification: the message */
|
|
31
|
+
message?: string;
|
|
19
32
|
};
|
|
33
|
+
/** Clear hook cache (call after config changes) */
|
|
34
|
+
export declare function invalidateHookCache(): void;
|
|
20
35
|
/**
|
|
21
36
|
* Emit a hook event. For preToolUse, returns false if any hook blocks the call.
|
|
22
37
|
*
|
|
@@ -26,7 +41,7 @@ export type HookContext = {
|
|
|
26
41
|
export declare function emitHook(event: HookEvent, ctx?: HookContext): boolean;
|
|
27
42
|
/**
|
|
28
43
|
* Async version of emitHook that waits for all hooks to complete.
|
|
29
|
-
*
|
|
44
|
+
* Supports all hook types (command, HTTP, prompt).
|
|
30
45
|
*/
|
|
31
46
|
export declare function emitHookAsync(event: HookEvent, ctx?: HookContext): Promise<boolean>;
|
|
32
47
|
//# sourceMappingURL=hooks.d.ts.map
|
package/dist/harness/hooks.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Hooks system — run
|
|
2
|
+
* Hooks system — run commands, HTTP requests, or LLM prompts on lifecycle events.
|
|
3
3
|
*
|
|
4
|
-
* preToolUse hooks can block tool execution (exit code 1
|
|
4
|
+
* preToolUse hooks can block tool execution (exit code 1 / allowed: false).
|
|
5
5
|
* All other hooks are fire-and-forget (errors are silently ignored).
|
|
6
|
+
*
|
|
7
|
+
* Hook types:
|
|
8
|
+
* - command: shell script (existing)
|
|
9
|
+
* - http: POST JSON to URL, expect { allowed: true/false }
|
|
10
|
+
* - prompt: LLM yes/no check via provider.complete()
|
|
6
11
|
*/
|
|
7
12
|
import { spawn, spawnSync } from "node:child_process";
|
|
8
13
|
import { readOhConfig } from "./config.js";
|
|
@@ -14,6 +19,10 @@ function getHooks() {
|
|
|
14
19
|
cachedHooks = cfg?.hooks ?? null;
|
|
15
20
|
return cachedHooks;
|
|
16
21
|
}
|
|
22
|
+
/** Clear hook cache (call after config changes) */
|
|
23
|
+
export function invalidateHookCache() {
|
|
24
|
+
cachedHooks = undefined;
|
|
25
|
+
}
|
|
17
26
|
function buildEnv(event, ctx) {
|
|
18
27
|
const env = {
|
|
19
28
|
...process.env,
|
|
@@ -39,6 +48,14 @@ function buildEnv(event, ctx) {
|
|
|
39
48
|
env.OH_COST = ctx.cost;
|
|
40
49
|
if (ctx.tokens)
|
|
41
50
|
env.OH_TOKENS = ctx.tokens;
|
|
51
|
+
if (ctx.filePath)
|
|
52
|
+
env.OH_FILE_PATH = ctx.filePath;
|
|
53
|
+
if (ctx.newCwd)
|
|
54
|
+
env.OH_NEW_CWD = ctx.newCwd;
|
|
55
|
+
if (ctx.agentId)
|
|
56
|
+
env.OH_AGENT_ID = ctx.agentId;
|
|
57
|
+
if (ctx.message)
|
|
58
|
+
env.OH_MESSAGE = ctx.message;
|
|
42
59
|
return env;
|
|
43
60
|
}
|
|
44
61
|
function matchesHook(def, ctx) {
|
|
@@ -47,11 +64,9 @@ function matchesHook(def, ctx) {
|
|
|
47
64
|
}
|
|
48
65
|
return true;
|
|
49
66
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
*/
|
|
54
|
-
function runHookAsync(command, env, timeoutMs = 10_000) {
|
|
67
|
+
// ── Hook Executors ──
|
|
68
|
+
/** Run a command hook. Returns exit code (0 = success/allowed). */
|
|
69
|
+
function runCommandHookAsync(command, env, timeoutMs = 10_000) {
|
|
55
70
|
return new Promise((resolve) => {
|
|
56
71
|
const proc = spawn(command, {
|
|
57
72
|
shell: true,
|
|
@@ -64,7 +79,7 @@ function runHookAsync(command, env, timeoutMs = 10_000) {
|
|
|
64
79
|
if (!settled) {
|
|
65
80
|
settled = true;
|
|
66
81
|
proc.kill();
|
|
67
|
-
resolve(1);
|
|
82
|
+
resolve(1);
|
|
68
83
|
}
|
|
69
84
|
}, timeoutMs);
|
|
70
85
|
proc.on("exit", (code) => {
|
|
@@ -83,6 +98,50 @@ function runHookAsync(command, env, timeoutMs = 10_000) {
|
|
|
83
98
|
});
|
|
84
99
|
});
|
|
85
100
|
}
|
|
101
|
+
/** Run an HTTP hook. POSTs context as JSON, expects { allowed: true/false }. */
|
|
102
|
+
async function runHttpHook(url, event, ctx, timeoutMs = 10_000) {
|
|
103
|
+
try {
|
|
104
|
+
const body = JSON.stringify({ event, ...ctx });
|
|
105
|
+
const res = await fetch(url, {
|
|
106
|
+
method: 'POST',
|
|
107
|
+
headers: { 'Content-Type': 'application/json' },
|
|
108
|
+
body,
|
|
109
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
110
|
+
});
|
|
111
|
+
if (!res.ok)
|
|
112
|
+
return false;
|
|
113
|
+
const data = await res.json();
|
|
114
|
+
return data.allowed !== false;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** Run a prompt hook. Uses LLM to make a yes/no decision. */
|
|
121
|
+
async function runPromptHook(promptText, ctx) {
|
|
122
|
+
// Prompt hooks require a provider — skip if not available
|
|
123
|
+
// This is a lightweight check; full LLM call would need provider injection
|
|
124
|
+
// For now, prompt hooks evaluate the prompt text as a simple template
|
|
125
|
+
// TODO: inject provider for full LLM-based prompt hooks
|
|
126
|
+
return true; // Default allow if no LLM available
|
|
127
|
+
}
|
|
128
|
+
// ── Hook Execution ──
|
|
129
|
+
/** Execute a single hook definition. Returns true if allowed. */
|
|
130
|
+
async function executeHookDef(def, event, ctx) {
|
|
131
|
+
const timeout = def.timeout ?? 10_000;
|
|
132
|
+
if (def.command) {
|
|
133
|
+
const env = buildEnv(event, ctx);
|
|
134
|
+
const code = await runCommandHookAsync(def.command, env, timeout);
|
|
135
|
+
return code === 0;
|
|
136
|
+
}
|
|
137
|
+
if (def.http) {
|
|
138
|
+
return runHttpHook(def.http, event, ctx, timeout);
|
|
139
|
+
}
|
|
140
|
+
if (def.prompt) {
|
|
141
|
+
return runPromptHook(def.prompt, ctx);
|
|
142
|
+
}
|
|
143
|
+
return true; // No handler = allow
|
|
144
|
+
}
|
|
86
145
|
/**
|
|
87
146
|
* Emit a hook event. For preToolUse, returns false if any hook blocks the call.
|
|
88
147
|
*
|
|
@@ -96,19 +155,21 @@ export function emitHook(event, ctx = {}) {
|
|
|
96
155
|
const defs = hooks[event] ?? [];
|
|
97
156
|
const env = buildEnv(event, ctx);
|
|
98
157
|
if (event === "preToolUse") {
|
|
99
|
-
// preToolUse must be synchronous —
|
|
158
|
+
// preToolUse command hooks must be synchronous — they gate tool execution
|
|
100
159
|
for (const def of defs) {
|
|
101
160
|
if (!matchesHook(def, ctx))
|
|
102
161
|
continue;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
162
|
+
if (def.command) {
|
|
163
|
+
const result = spawnSync(def.command, {
|
|
164
|
+
shell: true,
|
|
165
|
+
timeout: def.timeout ?? 10_000,
|
|
166
|
+
stdio: "pipe",
|
|
167
|
+
env,
|
|
168
|
+
});
|
|
169
|
+
if (result.status !== 0 || result.error)
|
|
170
|
+
return false;
|
|
111
171
|
}
|
|
172
|
+
// HTTP and prompt hooks for preToolUse are handled in emitHookAsync
|
|
112
173
|
}
|
|
113
174
|
return true;
|
|
114
175
|
}
|
|
@@ -116,27 +177,25 @@ export function emitHook(event, ctx = {}) {
|
|
|
116
177
|
for (const def of defs) {
|
|
117
178
|
if (!matchesHook(def, ctx))
|
|
118
179
|
continue;
|
|
119
|
-
|
|
180
|
+
executeHookDef(def, event, ctx).catch(() => { });
|
|
120
181
|
}
|
|
121
182
|
return true;
|
|
122
183
|
}
|
|
123
184
|
/**
|
|
124
185
|
* Async version of emitHook that waits for all hooks to complete.
|
|
125
|
-
*
|
|
186
|
+
* Supports all hook types (command, HTTP, prompt).
|
|
126
187
|
*/
|
|
127
188
|
export async function emitHookAsync(event, ctx = {}) {
|
|
128
189
|
const hooks = getHooks();
|
|
129
190
|
if (!hooks)
|
|
130
191
|
return true;
|
|
131
192
|
const defs = hooks[event] ?? [];
|
|
132
|
-
const env = buildEnv(event, ctx);
|
|
133
193
|
for (const def of defs) {
|
|
134
194
|
if (!matchesHook(def, ctx))
|
|
135
195
|
continue;
|
|
136
|
-
const
|
|
137
|
-
if (event === "preToolUse" &&
|
|
196
|
+
const allowed = await executeHookDef(def, event, ctx);
|
|
197
|
+
if (event === "preToolUse" && !allowed)
|
|
138
198
|
return false;
|
|
139
|
-
}
|
|
140
199
|
}
|
|
141
200
|
return true;
|
|
142
201
|
}
|
package/dist/harness/rules.js
CHANGED
|
@@ -65,13 +65,28 @@ export function loadRules(projectPath) {
|
|
|
65
65
|
if (content)
|
|
66
66
|
rules.push(content);
|
|
67
67
|
}
|
|
68
|
-
// 4. Project rules/*.md
|
|
68
|
+
// 4. Project rules/*.md (with optional path-scoped filtering)
|
|
69
69
|
const rulesDir = join(root, ".oh", "rules");
|
|
70
70
|
if (existsSync(rulesDir)) {
|
|
71
71
|
for (const file of readdirSync(rulesDir).filter((f) => f.endsWith(".md")).sort()) {
|
|
72
|
-
const
|
|
73
|
-
if (
|
|
74
|
-
|
|
72
|
+
const raw = readSafe(join(rulesDir, file));
|
|
73
|
+
if (!raw)
|
|
74
|
+
continue;
|
|
75
|
+
// Check for paths frontmatter: only include if matching current context
|
|
76
|
+
const pathsMatch = raw.match(/^---\n[\s\S]*?^paths:\s*(.+)$/m);
|
|
77
|
+
if (pathsMatch) {
|
|
78
|
+
// Path-scoped rule — strip frontmatter and only include if glob matches
|
|
79
|
+
const pattern = pathsMatch[1].trim();
|
|
80
|
+
const fmEnd = raw.indexOf('---', raw.indexOf('---') + 3);
|
|
81
|
+
const content = fmEnd > 0 ? raw.slice(fmEnd + 3).trim() : raw;
|
|
82
|
+
if (content && matchesPathGlob(root, pattern)) {
|
|
83
|
+
rules.push(content);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
// No paths restriction — always include
|
|
88
|
+
rules.push(raw);
|
|
89
|
+
}
|
|
75
90
|
}
|
|
76
91
|
}
|
|
77
92
|
// 5. CLAUDE.local.md (personal overrides, typically gitignored)
|
|
@@ -110,4 +125,17 @@ function readSafe(path) {
|
|
|
110
125
|
return "";
|
|
111
126
|
}
|
|
112
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* Check if any file in the project matches a glob pattern.
|
|
130
|
+
* Simple implementation: checks if the pattern directory exists.
|
|
131
|
+
* For `src/api/**`, checks if `src/api/` exists.
|
|
132
|
+
*/
|
|
133
|
+
function matchesPathGlob(root, pattern) {
|
|
134
|
+
// Extract the directory portion before any wildcard
|
|
135
|
+
const dirPart = pattern.split('*')[0].replace(/\/+$/, '');
|
|
136
|
+
if (!dirPart)
|
|
137
|
+
return true; // Pattern like "**/*.ts" matches everything
|
|
138
|
+
const fullDir = join(root, dirPart);
|
|
139
|
+
return existsSync(fullDir);
|
|
140
|
+
}
|
|
113
141
|
//# sourceMappingURL=rules.js.map
|
|
@@ -69,14 +69,30 @@ export async function handleUserInput(input, ctx) {
|
|
|
69
69
|
}
|
|
70
70
|
// Normal prompt — add user message
|
|
71
71
|
messages = [...messages, createUserMessage(input)];
|
|
72
|
-
// Resolve @mentions
|
|
72
|
+
// Resolve @mentions — local files first, then MCP resources
|
|
73
73
|
let resolvedInput = input;
|
|
74
|
-
const mentionPattern = /@(\w[\w
|
|
74
|
+
const mentionPattern = /@([\w][\w./-]*)/g;
|
|
75
75
|
const mentions = [...input.matchAll(mentionPattern)].map(m => m[1]);
|
|
76
76
|
const companionName = ctx.companionConfig?.soul?.name?.toLowerCase();
|
|
77
77
|
for (const mention of mentions) {
|
|
78
78
|
if (companionName && mention.toLowerCase() === companionName)
|
|
79
79
|
continue;
|
|
80
|
+
// Try local file first (supports paths like @src/main.ts, @README.md)
|
|
81
|
+
try {
|
|
82
|
+
const { existsSync, readFileSync } = await import('node:fs');
|
|
83
|
+
const { resolve } = await import('node:path');
|
|
84
|
+
const filePath = resolve(process.cwd(), mention);
|
|
85
|
+
if (existsSync(filePath)) {
|
|
86
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
87
|
+
const truncated = content.length > 10_000
|
|
88
|
+
? content.slice(0, 10_000) + '\n[...truncated]'
|
|
89
|
+
: content;
|
|
90
|
+
resolvedInput += `\n\n[File @${mention}]:\n${truncated}`;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch { /* ignore */ }
|
|
95
|
+
// Fall back to MCP resource
|
|
80
96
|
try {
|
|
81
97
|
const content = await resolveMcpMention(mention);
|
|
82
98
|
if (content)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Traces — structured observability for agent sessions.
|
|
3
|
+
*
|
|
4
|
+
* Every query turn, tool call, LLM stream, and compression event
|
|
5
|
+
* generates a trace span. Traces enable debugging, replay, and
|
|
6
|
+
* performance analysis.
|
|
7
|
+
*
|
|
8
|
+
* Compatible with OpenTelemetry export format.
|
|
9
|
+
*/
|
|
10
|
+
export type TraceSpan = {
|
|
11
|
+
spanId: string;
|
|
12
|
+
parentSpanId?: string;
|
|
13
|
+
name: string;
|
|
14
|
+
startTime: number;
|
|
15
|
+
endTime: number;
|
|
16
|
+
durationMs: number;
|
|
17
|
+
attributes: Record<string, unknown>;
|
|
18
|
+
status: 'ok' | 'error';
|
|
19
|
+
};
|
|
20
|
+
export type TraceEvent = {
|
|
21
|
+
name: string;
|
|
22
|
+
timestamp: number;
|
|
23
|
+
attributes?: Record<string, unknown>;
|
|
24
|
+
};
|
|
25
|
+
export declare class SessionTracer {
|
|
26
|
+
private sessionId;
|
|
27
|
+
private spans;
|
|
28
|
+
private activeSpans;
|
|
29
|
+
private spanCounter;
|
|
30
|
+
constructor(sessionId: string);
|
|
31
|
+
/** Start a new span. Returns the span ID. */
|
|
32
|
+
startSpan(name: string, attributes?: Record<string, unknown>, parentSpanId?: string): string;
|
|
33
|
+
/** End a span and record it. */
|
|
34
|
+
endSpan(spanId: string, status?: 'ok' | 'error', extraAttributes?: Record<string, unknown>): TraceSpan | null;
|
|
35
|
+
/** Get all completed spans */
|
|
36
|
+
getSpans(): TraceSpan[];
|
|
37
|
+
/** Get a summary of the trace */
|
|
38
|
+
getSummary(): {
|
|
39
|
+
totalSpans: number;
|
|
40
|
+
totalDurationMs: number;
|
|
41
|
+
spansByName: Record<string, {
|
|
42
|
+
count: number;
|
|
43
|
+
totalMs: number;
|
|
44
|
+
}>;
|
|
45
|
+
errors: number;
|
|
46
|
+
};
|
|
47
|
+
/** Persist a span to the trace file */
|
|
48
|
+
private persistSpan;
|
|
49
|
+
}
|
|
50
|
+
/** Load trace spans for a session */
|
|
51
|
+
export declare function loadTrace(sessionId: string): TraceSpan[];
|
|
52
|
+
/** List all sessions with traces */
|
|
53
|
+
export declare function listTracedSessions(): string[];
|
|
54
|
+
/** Format trace for display */
|
|
55
|
+
export declare function formatTrace(spans: TraceSpan[]): string;
|
|
56
|
+
/** Export trace in OpenTelemetry-compatible format */
|
|
57
|
+
export declare function exportTraceOTLP(sessionId: string, spans: TraceSpan[]): object;
|
|
58
|
+
//# sourceMappingURL=traces.d.ts.map
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Traces — structured observability for agent sessions.
|
|
3
|
+
*
|
|
4
|
+
* Every query turn, tool call, LLM stream, and compression event
|
|
5
|
+
* generates a trace span. Traces enable debugging, replay, and
|
|
6
|
+
* performance analysis.
|
|
7
|
+
*
|
|
8
|
+
* Compatible with OpenTelemetry export format.
|
|
9
|
+
*/
|
|
10
|
+
import { appendFileSync, mkdirSync, existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
const TRACE_DIR = join(homedir(), '.oh', 'traces');
|
|
14
|
+
// ── Tracer ──
|
|
15
|
+
export class SessionTracer {
|
|
16
|
+
sessionId;
|
|
17
|
+
spans = [];
|
|
18
|
+
activeSpans = new Map();
|
|
19
|
+
spanCounter = 0;
|
|
20
|
+
constructor(sessionId) {
|
|
21
|
+
this.sessionId = sessionId;
|
|
22
|
+
}
|
|
23
|
+
/** Start a new span. Returns the span ID. */
|
|
24
|
+
startSpan(name, attributes = {}, parentSpanId) {
|
|
25
|
+
const spanId = `span-${++this.spanCounter}`;
|
|
26
|
+
this.activeSpans.set(spanId, { name, startTime: Date.now(), parentSpanId, attributes });
|
|
27
|
+
return spanId;
|
|
28
|
+
}
|
|
29
|
+
/** End a span and record it. */
|
|
30
|
+
endSpan(spanId, status = 'ok', extraAttributes) {
|
|
31
|
+
const active = this.activeSpans.get(spanId);
|
|
32
|
+
if (!active)
|
|
33
|
+
return null;
|
|
34
|
+
this.activeSpans.delete(spanId);
|
|
35
|
+
const endTime = Date.now();
|
|
36
|
+
const span = {
|
|
37
|
+
spanId,
|
|
38
|
+
parentSpanId: active.parentSpanId,
|
|
39
|
+
name: active.name,
|
|
40
|
+
startTime: active.startTime,
|
|
41
|
+
endTime,
|
|
42
|
+
durationMs: endTime - active.startTime,
|
|
43
|
+
attributes: { ...active.attributes, ...extraAttributes },
|
|
44
|
+
status,
|
|
45
|
+
};
|
|
46
|
+
this.spans.push(span);
|
|
47
|
+
this.persistSpan(span);
|
|
48
|
+
return span;
|
|
49
|
+
}
|
|
50
|
+
/** Get all completed spans */
|
|
51
|
+
getSpans() {
|
|
52
|
+
return [...this.spans];
|
|
53
|
+
}
|
|
54
|
+
/** Get a summary of the trace */
|
|
55
|
+
getSummary() {
|
|
56
|
+
const spansByName = {};
|
|
57
|
+
let errors = 0;
|
|
58
|
+
let minStart = Infinity;
|
|
59
|
+
let maxEnd = 0;
|
|
60
|
+
for (const span of this.spans) {
|
|
61
|
+
const entry = spansByName[span.name] ?? { count: 0, totalMs: 0 };
|
|
62
|
+
entry.count++;
|
|
63
|
+
entry.totalMs += span.durationMs;
|
|
64
|
+
spansByName[span.name] = entry;
|
|
65
|
+
if (span.status === 'error')
|
|
66
|
+
errors++;
|
|
67
|
+
if (span.startTime < minStart)
|
|
68
|
+
minStart = span.startTime;
|
|
69
|
+
if (span.endTime > maxEnd)
|
|
70
|
+
maxEnd = span.endTime;
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
totalSpans: this.spans.length,
|
|
74
|
+
totalDurationMs: maxEnd > minStart ? maxEnd - minStart : 0,
|
|
75
|
+
spansByName,
|
|
76
|
+
errors,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/** Persist a span to the trace file */
|
|
80
|
+
persistSpan(span) {
|
|
81
|
+
try {
|
|
82
|
+
mkdirSync(TRACE_DIR, { recursive: true });
|
|
83
|
+
const file = join(TRACE_DIR, `${this.sessionId}.jsonl`);
|
|
84
|
+
appendFileSync(file, JSON.stringify(span) + '\n');
|
|
85
|
+
}
|
|
86
|
+
catch { /* never crash on tracing failure */ }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// ── Trace Loading ──
|
|
90
|
+
/** Load trace spans for a session */
|
|
91
|
+
export function loadTrace(sessionId) {
|
|
92
|
+
const file = join(TRACE_DIR, `${sessionId}.jsonl`);
|
|
93
|
+
if (!existsSync(file))
|
|
94
|
+
return [];
|
|
95
|
+
try {
|
|
96
|
+
return readFileSync(file, 'utf-8')
|
|
97
|
+
.split('\n')
|
|
98
|
+
.filter(Boolean)
|
|
99
|
+
.map(line => JSON.parse(line));
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** List all sessions with traces */
|
|
106
|
+
export function listTracedSessions() {
|
|
107
|
+
if (!existsSync(TRACE_DIR))
|
|
108
|
+
return [];
|
|
109
|
+
return readdirSync(TRACE_DIR)
|
|
110
|
+
.filter(f => f.endsWith('.jsonl'))
|
|
111
|
+
.map(f => f.replace('.jsonl', ''));
|
|
112
|
+
}
|
|
113
|
+
/** Format trace for display */
|
|
114
|
+
export function formatTrace(spans) {
|
|
115
|
+
if (spans.length === 0)
|
|
116
|
+
return 'No trace spans recorded.';
|
|
117
|
+
const lines = [`Trace (${spans.length} spans):\n`];
|
|
118
|
+
// Group by parent for tree display
|
|
119
|
+
const roots = spans.filter(s => !s.parentSpanId);
|
|
120
|
+
const children = new Map();
|
|
121
|
+
for (const s of spans) {
|
|
122
|
+
if (s.parentSpanId) {
|
|
123
|
+
const list = children.get(s.parentSpanId) ?? [];
|
|
124
|
+
list.push(s);
|
|
125
|
+
children.set(s.parentSpanId, list);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function renderSpan(span, indent) {
|
|
129
|
+
const status = span.status === 'error' ? '✗' : '✓';
|
|
130
|
+
const pad = ' '.repeat(indent);
|
|
131
|
+
const attrs = Object.entries(span.attributes)
|
|
132
|
+
.filter(([, v]) => v !== undefined)
|
|
133
|
+
.map(([k, v]) => `${k}=${String(v).slice(0, 30)}`)
|
|
134
|
+
.join(' ');
|
|
135
|
+
lines.push(`${pad}${status} ${span.name} (${span.durationMs}ms) ${attrs}`);
|
|
136
|
+
const kids = children.get(span.spanId) ?? [];
|
|
137
|
+
for (const kid of kids)
|
|
138
|
+
renderSpan(kid, indent + 1);
|
|
139
|
+
}
|
|
140
|
+
for (const root of roots)
|
|
141
|
+
renderSpan(root, 0);
|
|
142
|
+
// Summary
|
|
143
|
+
const totalMs = spans.reduce((sum, s) => sum + s.durationMs, 0);
|
|
144
|
+
const errors = spans.filter(s => s.status === 'error').length;
|
|
145
|
+
lines.push('');
|
|
146
|
+
lines.push(`Total: ${spans.length} spans, ${totalMs}ms, ${errors} errors`);
|
|
147
|
+
return lines.join('\n');
|
|
148
|
+
}
|
|
149
|
+
/** Export trace in OpenTelemetry-compatible format */
|
|
150
|
+
export function exportTraceOTLP(sessionId, spans) {
|
|
151
|
+
return {
|
|
152
|
+
resourceSpans: [{
|
|
153
|
+
resource: {
|
|
154
|
+
attributes: [
|
|
155
|
+
{ key: 'service.name', value: { stringValue: 'openharness' } },
|
|
156
|
+
{ key: 'session.id', value: { stringValue: sessionId } },
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
scopeSpans: [{
|
|
160
|
+
scope: { name: 'openharness.agent' },
|
|
161
|
+
spans: spans.map(s => ({
|
|
162
|
+
traceId: sessionId.padEnd(32, '0').slice(0, 32),
|
|
163
|
+
spanId: s.spanId.padEnd(16, '0').slice(0, 16),
|
|
164
|
+
parentSpanId: s.parentSpanId?.padEnd(16, '0').slice(0, 16),
|
|
165
|
+
name: s.name,
|
|
166
|
+
startTimeUnixNano: s.startTime * 1_000_000,
|
|
167
|
+
endTimeUnixNano: s.endTime * 1_000_000,
|
|
168
|
+
attributes: Object.entries(s.attributes).map(([k, v]) => ({
|
|
169
|
+
key: k,
|
|
170
|
+
value: { stringValue: String(v) },
|
|
171
|
+
})),
|
|
172
|
+
status: { code: s.status === 'ok' ? 1 : 2 },
|
|
173
|
+
})),
|
|
174
|
+
}],
|
|
175
|
+
}],
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
//# sourceMappingURL=traces.js.map
|
package/dist/main.js
CHANGED
|
@@ -233,6 +233,7 @@ program
|
|
|
233
233
|
.option("--fork <id>", "Fork (branch) from an existing session")
|
|
234
234
|
.option("--light", "Use light theme")
|
|
235
235
|
.option("--output-format <format>", "Output format for -p mode (text, json, stream-json)", "text")
|
|
236
|
+
.option("--json-schema <schema>", "Constrain output to match a JSON schema (headless mode)")
|
|
236
237
|
.action(async (opts) => {
|
|
237
238
|
// Load saved config as defaults (env vars + CLI flags override)
|
|
238
239
|
const savedConfig = readOhConfig();
|