@zhijiewang/openharness 1.0.0 → 1.2.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.
@@ -29,7 +29,7 @@ register("help", "Show available commands", () => {
29
29
  'Git': ['diff', 'undo', 'rewind', 'commit', 'log'],
30
30
  'Info': ['help', 'cost', 'status', 'config', 'files', 'model', 'memory', 'doctor', 'context', 'mcp', 'mcp-registry'],
31
31
  'Settings': ['theme', 'vim', 'companion', 'fast', 'keys'],
32
- 'AI': ['plan', 'review', 'roles'],
32
+ 'AI': ['plan', 'review', 'roles', 'agents', 'plugins'],
33
33
  'Pet': ['cybergotchi'],
34
34
  };
35
35
  const lines = [];
@@ -354,6 +354,27 @@ register("roles", "List available agent specialization roles", () => {
354
354
  lines.push("Usage: Agent({ subagent_type: 'code-reviewer', prompt: '...' })");
355
355
  return { output: lines.join("\n"), handled: true };
356
356
  });
357
+ register("agents", "Discover running openHarness agents on this machine", () => {
358
+ const { discoverAgents } = require('../services/a2a.js');
359
+ const agents = discoverAgents();
360
+ if (agents.length === 0) {
361
+ return { output: "No other openHarness agents running on this machine.\n\nOther oh sessions will appear here automatically via the A2A protocol.", handled: true };
362
+ }
363
+ const lines = [`Running Agents (${agents.length}):\n`];
364
+ for (const agent of agents) {
365
+ const age = Math.round((Date.now() - agent.registeredAt) / 60_000);
366
+ lines.push(` ${agent.name}`);
367
+ lines.push(` ID: ${agent.id}`);
368
+ lines.push(` Provider: ${agent.provider ?? 'unknown'} / ${agent.model ?? 'unknown'}`);
369
+ lines.push(` Dir: ${agent.workingDir ?? 'unknown'}`);
370
+ lines.push(` Endpoint: ${agent.endpoint.type}${agent.endpoint.port ? ':' + agent.endpoint.port : ''}`);
371
+ lines.push(` Uptime: ${age}m`);
372
+ lines.push(` Caps: ${agent.capabilities.map((c) => c.name).join(', ')}`);
373
+ lines.push('');
374
+ }
375
+ lines.push("Send messages with: Agent({ prompt: 'ask the other agent...', allowed_tools: ['SendMessage'] })");
376
+ return { output: lines.join("\n"), handled: true };
377
+ });
357
378
  register("fast", "Toggle fast mode (optimized for speed)", () => {
358
379
  return { output: "", handled: true, toggleFastMode: true };
359
380
  });
@@ -51,6 +51,23 @@ export type OhConfig = {
51
51
  memory?: {
52
52
  consolidateOnExit?: boolean;
53
53
  };
54
+ /** Multi-model router — use different models for different task types */
55
+ modelRouter?: {
56
+ fast?: string;
57
+ balanced?: string;
58
+ powerful?: string;
59
+ };
60
+ /** Opt-in telemetry (default: off) */
61
+ telemetry?: {
62
+ enabled?: boolean;
63
+ endpoint?: string;
64
+ };
65
+ /** Remote server security settings */
66
+ remote?: {
67
+ tokens?: string[];
68
+ rateLimit?: number;
69
+ allowedTools?: string[];
70
+ };
54
71
  };
55
72
  /** Clear cached config (call after writes or to force re-read) */
56
73
  export declare function invalidateConfigCache(): void;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Opt-in telemetry — anonymous usage tracking for feature prioritization.
3
+ *
4
+ * Default: OFF. Enable via config.yaml:
5
+ * telemetry:
6
+ * enabled: true
7
+ *
8
+ * Privacy: never logs file paths, prompts, tool output, or API keys.
9
+ * Only tracks: tool names, durations, error categories, session metadata.
10
+ *
11
+ * Events are batched locally as JSONL in ~/.oh/telemetry/.
12
+ * Optional: POST to configurable endpoint on session end.
13
+ */
14
+ export type TelemetryEvent = {
15
+ type: 'session_start' | 'tool_call' | 'error' | 'session_end';
16
+ timestamp: number;
17
+ sessionId: string;
18
+ payload: TelemetryPayload;
19
+ };
20
+ export type TelemetryPayload = {
21
+ provider?: string;
22
+ model?: string;
23
+ platform?: string;
24
+ toolName?: string;
25
+ durationMs?: number;
26
+ isError?: boolean;
27
+ errorCategory?: string;
28
+ totalTurns?: number;
29
+ totalCost?: number;
30
+ totalToolCalls?: number;
31
+ durationMinutes?: number;
32
+ };
33
+ /** Record a telemetry event (no-op if telemetry disabled) */
34
+ export declare function recordEvent(event: TelemetryEvent): void;
35
+ /** Convenience: record a tool call event */
36
+ export declare function recordToolCall(sessionId: string, toolName: string, durationMs: number, isError: boolean): void;
37
+ /** Convenience: record session start */
38
+ export declare function recordSessionStart(sessionId: string, provider: string, model: string): void;
39
+ /** Convenience: record session end with stats */
40
+ export declare function recordSessionEnd(sessionId: string, stats: {
41
+ totalTurns: number;
42
+ totalCost: number;
43
+ totalToolCalls: number;
44
+ durationMinutes: number;
45
+ }): void;
46
+ /** Convenience: record an error */
47
+ export declare function recordError(sessionId: string, category: string): void;
48
+ /** Read local telemetry events for a session */
49
+ export declare function readSessionEvents(sessionId: string): TelemetryEvent[];
50
+ /** Get aggregate stats across all sessions */
51
+ export declare function getAggregateStats(): {
52
+ totalSessions: number;
53
+ totalEvents: number;
54
+ toolUsage: Record<string, number>;
55
+ errorCategories: Record<string, number>;
56
+ };
57
+ /** Reset telemetry cache (for testing or config changes) */
58
+ export declare function resetTelemetry(): void;
59
+ //# sourceMappingURL=telemetry.d.ts.map
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Opt-in telemetry — anonymous usage tracking for feature prioritization.
3
+ *
4
+ * Default: OFF. Enable via config.yaml:
5
+ * telemetry:
6
+ * enabled: true
7
+ *
8
+ * Privacy: never logs file paths, prompts, tool output, or API keys.
9
+ * Only tracks: tool names, durations, error categories, session metadata.
10
+ *
11
+ * Events are batched locally as JSONL in ~/.oh/telemetry/.
12
+ * Optional: POST to configurable endpoint on session end.
13
+ */
14
+ import { appendFileSync, mkdirSync, existsSync, readdirSync, readFileSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { homedir } from 'node:os';
17
+ import { readOhConfig } from './config.js';
18
+ const TELEMETRY_DIR = join(homedir(), '.oh', 'telemetry');
19
+ // ── State ──
20
+ let _enabled;
21
+ let _sessionFile = null;
22
+ function isEnabled() {
23
+ if (_enabled !== undefined)
24
+ return _enabled;
25
+ const config = readOhConfig();
26
+ _enabled = config?.telemetry?.enabled === true;
27
+ return _enabled;
28
+ }
29
+ function getSessionFile(sessionId) {
30
+ if (_sessionFile)
31
+ return _sessionFile;
32
+ mkdirSync(TELEMETRY_DIR, { recursive: true });
33
+ _sessionFile = join(TELEMETRY_DIR, `${sessionId}.jsonl`);
34
+ return _sessionFile;
35
+ }
36
+ // ── Public API ──
37
+ /** Record a telemetry event (no-op if telemetry disabled) */
38
+ export function recordEvent(event) {
39
+ if (!isEnabled())
40
+ return;
41
+ try {
42
+ const file = getSessionFile(event.sessionId);
43
+ appendFileSync(file, JSON.stringify(event) + '\n');
44
+ }
45
+ catch { /* never crash on telemetry failure */ }
46
+ }
47
+ /** Convenience: record a tool call event */
48
+ export function recordToolCall(sessionId, toolName, durationMs, isError) {
49
+ recordEvent({
50
+ type: 'tool_call',
51
+ timestamp: Date.now(),
52
+ sessionId,
53
+ payload: { toolName, durationMs, isError },
54
+ });
55
+ }
56
+ /** Convenience: record session start */
57
+ export function recordSessionStart(sessionId, provider, model) {
58
+ recordEvent({
59
+ type: 'session_start',
60
+ timestamp: Date.now(),
61
+ sessionId,
62
+ payload: { provider, model, platform: process.platform },
63
+ });
64
+ }
65
+ /** Convenience: record session end with stats */
66
+ export function recordSessionEnd(sessionId, stats) {
67
+ recordEvent({
68
+ type: 'session_end',
69
+ timestamp: Date.now(),
70
+ sessionId,
71
+ payload: stats,
72
+ });
73
+ }
74
+ /** Convenience: record an error */
75
+ export function recordError(sessionId, category) {
76
+ recordEvent({
77
+ type: 'error',
78
+ timestamp: Date.now(),
79
+ sessionId,
80
+ payload: { errorCategory: category },
81
+ });
82
+ }
83
+ /** Read local telemetry events for a session */
84
+ export function readSessionEvents(sessionId) {
85
+ const file = join(TELEMETRY_DIR, `${sessionId}.jsonl`);
86
+ if (!existsSync(file))
87
+ return [];
88
+ try {
89
+ return readFileSync(file, 'utf-8')
90
+ .split('\n')
91
+ .filter(Boolean)
92
+ .map(line => JSON.parse(line));
93
+ }
94
+ catch {
95
+ return [];
96
+ }
97
+ }
98
+ /** Get aggregate stats across all sessions */
99
+ export function getAggregateStats() {
100
+ if (!existsSync(TELEMETRY_DIR))
101
+ return { totalSessions: 0, totalEvents: 0, toolUsage: {}, errorCategories: {} };
102
+ const files = readdirSync(TELEMETRY_DIR).filter(f => f.endsWith('.jsonl'));
103
+ const toolUsage = {};
104
+ const errorCategories = {};
105
+ let totalEvents = 0;
106
+ for (const file of files) {
107
+ try {
108
+ const lines = readFileSync(join(TELEMETRY_DIR, file), 'utf-8').split('\n').filter(Boolean);
109
+ totalEvents += lines.length;
110
+ for (const line of lines) {
111
+ const event = JSON.parse(line);
112
+ if (event.type === 'tool_call' && event.payload.toolName) {
113
+ toolUsage[event.payload.toolName] = (toolUsage[event.payload.toolName] ?? 0) + 1;
114
+ }
115
+ if (event.type === 'error' && event.payload.errorCategory) {
116
+ errorCategories[event.payload.errorCategory] = (errorCategories[event.payload.errorCategory] ?? 0) + 1;
117
+ }
118
+ }
119
+ }
120
+ catch { /* skip malformed files */ }
121
+ }
122
+ return { totalSessions: files.length, totalEvents, toolUsage, errorCategories };
123
+ }
124
+ /** Reset telemetry cache (for testing or config changes) */
125
+ export function resetTelemetry() {
126
+ _enabled = undefined;
127
+ _sessionFile = null;
128
+ }
129
+ //# sourceMappingURL=telemetry.js.map
package/dist/main.js CHANGED
@@ -15,13 +15,13 @@ import { join } from "node:path";
15
15
  import { createRequire } from 'node:module';
16
16
  const _require = createRequire(import.meta.url);
17
17
  const VERSION = _require('../package.json').version;
18
- const BANNER = ` ___
19
- / \\
20
- ( ) ___ ___ ___ _ _ _ _ _ ___ _ _ ___ ___ ___
21
- \`~w~\` / _ \\| _ \\| __| \\| | || | /_\\ | _ \\ \\| | __/ __/ __|
22
- (( )) | (_) | _/| _|| .\` | __ |/ _ \\| / .\` | _|\\__ \\__ \\
23
- ))(( \\___/|_| |___|_|\\_|_||_/_/ \\_\\_|_\\_|\\_|___|___/___/
24
- (( ))
18
+ const BANNER = ` ___
19
+ / \\
20
+ ( ) ___ ___ ___ _ _ _ _ _ ___ _ _ ___ ___ ___
21
+ \`~w~\` / _ \\| _ \\| __| \\| | || | /_\\ | _ \\ \\| | __/ __/ __|
22
+ (( )) | (_) | _/| _|| .\` | __ |/ _ \\| / .\` | _|\\__ \\__ \\
23
+ ))(( \\___/|_| |___|_|\\_|_||_/_/ \\_\\_|_\\_|\\_|___|___/___/
24
+ (( ))
25
25
  \`--\``;
26
26
  const program = new Command();
27
27
  program
@@ -29,39 +29,39 @@ program
29
29
  .description("Open-source terminal coding agent. Works with any LLM.")
30
30
  .version(VERSION);
31
31
  // ── Headless run command ──
32
- const DEFAULT_SYSTEM_PROMPT = `You are OpenHarness, an AI coding assistant running in the user's terminal.
33
- You have access to tools for reading, writing, and searching files, running shell commands, and more.
34
-
35
- # Tool usage
36
- - Use Read (not cat/head/tail) to read files. Use Edit (not sed/awk) to modify files. Use Write only to create new files or complete rewrites. Use Grep (not grep/rg) to search content. Use Glob (not find) to find files by pattern. Use Bash only for shell commands that dedicated tools cannot handle.
37
- - Read a file before editing it. Understand existing code before suggesting modifications.
38
- - Prefer editing existing files over creating new ones.
39
- - You can call multiple tools in a single response. Call independent tools in parallel for efficiency. Call dependent tools sequentially.
40
-
41
- # Coding standards
42
- - Do not add features, refactor code, or make improvements beyond what was asked.
43
- - Do not add comments, docstrings, or type annotations to code you didn't change.
44
- - Do not add error handling or validation for scenarios that can't happen.
45
- - Do not create abstractions for one-time operations. Three similar lines is better than a premature abstraction.
46
- - Be careful not to introduce security vulnerabilities (command injection, XSS, SQL injection, etc.).
47
- - If you wrote insecure code, fix it immediately.
48
-
49
- # Git safety
50
- - NEVER run destructive git commands (push --force, reset --hard, checkout ., clean -f, branch -D) unless the user explicitly requests it.
51
- - NEVER skip hooks (--no-verify) or bypass signing (--no-gpg-sign) unless the user explicitly asks.
52
- - Prefer creating NEW commits over amending existing ones.
53
- - Before staging, prefer adding specific files by name rather than "git add -A" which can include sensitive files.
54
- - Only commit when the user explicitly asks you to.
55
-
56
- # Careful actions
57
- - For actions that are hard to reverse or affect shared systems, check with the user before proceeding.
58
- - Do not use destructive actions as shortcuts. Investigate root causes rather than bypassing safety checks.
59
- - If you discover unexpected state (unfamiliar files, branches, config), investigate before deleting or overwriting.
60
-
61
- # Output style
62
- - Be concise. Lead with the answer or action, not the reasoning.
63
- - When referencing code, include file_path:line_number.
64
- - Do not restate what the user said. Do not add trailing summaries unless asked.
32
+ const DEFAULT_SYSTEM_PROMPT = `You are OpenHarness, an AI coding assistant running in the user's terminal.
33
+ You have access to tools for reading, writing, and searching files, running shell commands, and more.
34
+
35
+ # Tool usage
36
+ - Use Read (not cat/head/tail) to read files. Use Edit (not sed/awk) to modify files. Use Write only to create new files or complete rewrites. Use Grep (not grep/rg) to search content. Use Glob (not find) to find files by pattern. Use Bash only for shell commands that dedicated tools cannot handle.
37
+ - Read a file before editing it. Understand existing code before suggesting modifications.
38
+ - Prefer editing existing files over creating new ones.
39
+ - You can call multiple tools in a single response. Call independent tools in parallel for efficiency. Call dependent tools sequentially.
40
+
41
+ # Coding standards
42
+ - Do not add features, refactor code, or make improvements beyond what was asked.
43
+ - Do not add comments, docstrings, or type annotations to code you didn't change.
44
+ - Do not add error handling or validation for scenarios that can't happen.
45
+ - Do not create abstractions for one-time operations. Three similar lines is better than a premature abstraction.
46
+ - Be careful not to introduce security vulnerabilities (command injection, XSS, SQL injection, etc.).
47
+ - If you wrote insecure code, fix it immediately.
48
+
49
+ # Git safety
50
+ - NEVER run destructive git commands (push --force, reset --hard, checkout ., clean -f, branch -D) unless the user explicitly requests it.
51
+ - NEVER skip hooks (--no-verify) or bypass signing (--no-gpg-sign) unless the user explicitly asks.
52
+ - Prefer creating NEW commits over amending existing ones.
53
+ - Before staging, prefer adding specific files by name rather than "git add -A" which can include sensitive files.
54
+ - Only commit when the user explicitly asks you to.
55
+
56
+ # Careful actions
57
+ - For actions that are hard to reverse or affect shared systems, check with the user before proceeding.
58
+ - Do not use destructive actions as shortcuts. Investigate root causes rather than bypassing safety checks.
59
+ - If you discover unexpected state (unfamiliar files, branches, config), investigate before deleting or overwriting.
60
+
61
+ # Output style
62
+ - Be concise. Lead with the answer or action, not the reasoning.
63
+ - When referencing code, include file_path:line_number.
64
+ - Do not restate what the user said. Do not add trailing summaries unless asked.
65
65
  - Keep responses short and direct. If you can say it in one sentence, don't use three.`;
66
66
  function buildSystemPrompt(model) {
67
67
  const parts = [DEFAULT_SYSTEM_PROMPT];
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Multi-Model Router — task-aware model selection.
3
+ *
4
+ * Routes LLM calls to the most appropriate model based on task context:
5
+ * - Fast model for exploration, search, and tool-heavy turns
6
+ * - Powerful model for final responses, code review, and complex reasoning
7
+ * - Balanced model as the default
8
+ *
9
+ * Saves ~20% cost and ~30% latency by avoiding expensive models for simple tasks.
10
+ */
11
+ export type ModelTier = 'fast' | 'balanced' | 'powerful';
12
+ export type RouterConfig = {
13
+ fast?: string;
14
+ balanced?: string;
15
+ powerful?: string;
16
+ };
17
+ export type RouteContext = {
18
+ /** Current turn number (1-indexed) */
19
+ turn: number;
20
+ /** Whether the previous turn had tool calls */
21
+ hadToolCalls: boolean;
22
+ /** Number of tool calls in previous turn */
23
+ toolCallCount: number;
24
+ /** Agent role (if sub-agent) */
25
+ role?: string;
26
+ /** Estimated context usage (0-1) */
27
+ contextUsage?: number;
28
+ /** Whether this is likely the final response (no tool calls expected) */
29
+ isFinalResponse?: boolean;
30
+ };
31
+ export type RouteResult = {
32
+ model: string;
33
+ tier: ModelTier;
34
+ reason: string;
35
+ };
36
+ export declare class ModelRouter {
37
+ private config;
38
+ private defaultModel;
39
+ constructor(config: RouterConfig, defaultModel: string);
40
+ /** Select the best model for the current context */
41
+ select(context: RouteContext): RouteResult;
42
+ private route;
43
+ /** Whether this router has any non-default models configured */
44
+ get isConfigured(): boolean;
45
+ /** Get all configured tiers */
46
+ get tiers(): Record<ModelTier, string>;
47
+ }
48
+ //# sourceMappingURL=router.d.ts.map
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Multi-Model Router — task-aware model selection.
3
+ *
4
+ * Routes LLM calls to the most appropriate model based on task context:
5
+ * - Fast model for exploration, search, and tool-heavy turns
6
+ * - Powerful model for final responses, code review, and complex reasoning
7
+ * - Balanced model as the default
8
+ *
9
+ * Saves ~20% cost and ~30% latency by avoiding expensive models for simple tasks.
10
+ */
11
+ export class ModelRouter {
12
+ config;
13
+ defaultModel;
14
+ constructor(config, defaultModel) {
15
+ this.config = config;
16
+ this.defaultModel = defaultModel;
17
+ }
18
+ /** Select the best model for the current context */
19
+ select(context) {
20
+ // High-context pressure → use fast model to minimize token cost
21
+ if (context.contextUsage && context.contextUsage > 0.8) {
22
+ return this.route('fast', 'context pressure > 80%');
23
+ }
24
+ // Roles that require deep reasoning → powerful
25
+ const powerfulRoles = ['code-reviewer', 'evaluator', 'architect', 'security-auditor'];
26
+ if (context.role && powerfulRoles.includes(context.role)) {
27
+ return this.route('powerful', `role: ${context.role}`);
28
+ }
29
+ // Early exploration turns (1-2) → fast
30
+ if (context.turn <= 2 && context.hadToolCalls) {
31
+ return this.route('fast', 'early exploration');
32
+ }
33
+ // Tool-heavy turns (3+ tool calls) → fast (just dispatching)
34
+ if (context.toolCallCount >= 3) {
35
+ return this.route('fast', 'tool-heavy turn');
36
+ }
37
+ // Final response (no tool calls) → powerful for quality
38
+ if (context.isFinalResponse) {
39
+ return this.route('powerful', 'final response');
40
+ }
41
+ // Default → balanced
42
+ return this.route('balanced', 'default');
43
+ }
44
+ route(tier, reason) {
45
+ const model = this.config[tier] ?? this.defaultModel;
46
+ return { model, tier, reason };
47
+ }
48
+ /** Whether this router has any non-default models configured */
49
+ get isConfigured() {
50
+ return !!(this.config.fast || this.config.balanced || this.config.powerful);
51
+ }
52
+ /** Get all configured tiers */
53
+ get tiers() {
54
+ return {
55
+ fast: this.config.fast ?? this.defaultModel,
56
+ balanced: this.config.balanced ?? this.defaultModel,
57
+ powerful: this.config.powerful ?? this.defaultModel,
58
+ };
59
+ }
60
+ }
61
+ //# sourceMappingURL=router.js.map
@@ -4,6 +4,11 @@
4
4
  */
5
5
  import type { Message } from "../types/message.js";
6
6
  import type { Provider } from "../providers/base.js";
7
+ /**
8
+ * Semantic importance scoring for messages.
9
+ * Higher score = more important to keep during compression.
10
+ */
11
+ export declare function scoreMessage(msg: Message, index: number, total: number): number;
7
12
  export declare function makeTokenEstimator(provider: Provider): (text: string) => number;
8
13
  export declare function estimateMessagesTokens(messages: Message[], estimateTokens?: (text: string) => number): number;
9
14
  /**
@@ -5,6 +5,35 @@
5
5
  import { createUserMessage } from "../types/message.js";
6
6
  import { defaultEstimateTokens } from "../providers/base.js";
7
7
  const DEFAULT_KEEP_LAST = 10;
8
+ /**
9
+ * Semantic importance scoring for messages.
10
+ * Higher score = more important to keep during compression.
11
+ */
12
+ export function scoreMessage(msg, index, total) {
13
+ if (msg.meta?.pinned)
14
+ return Infinity;
15
+ let score = 0;
16
+ // Role weight: user intent > tool decisions > assistant text
17
+ if (msg.role === 'user')
18
+ score += 30;
19
+ else if (msg.role === 'assistant' && msg.toolCalls?.length)
20
+ score += 20;
21
+ else if (msg.role === 'assistant')
22
+ score += 10;
23
+ else if (msg.role === 'system')
24
+ score += 25; // system messages are usually important
25
+ else if (msg.role === 'tool')
26
+ score += 5;
27
+ // Recency bonus: recent messages get +0 to +20
28
+ const recencyFactor = index / total;
29
+ score += recencyFactor * 20;
30
+ // Content length (longer = more substantive, but cap benefit)
31
+ score += Math.min(msg.content.length / 200, 5);
32
+ // Tool calls indicate decision points
33
+ if (msg.toolCalls?.length)
34
+ score += msg.toolCalls.length * 3;
35
+ return score;
36
+ }
8
37
  export function makeTokenEstimator(provider) {
9
38
  if (provider.estimateTokens)
10
39
  return provider.estimateTokens.bind(provider);
@@ -58,12 +87,24 @@ export function compressMessages(messages, targetTokens) {
58
87
  result[i] = { ...result[i], content: "[previous tool result truncated]" };
59
88
  }
60
89
  }
61
- // AutoCompact Phase 2: Drop oldest non-system, non-pinned messages
90
+ // AutoCompact Phase 2: Drop lowest-importance messages first (importance-aware)
62
91
  while (estimateMessagesTokens(result) > targetTokens && result.length > keepLast + 1) {
63
- const firstDroppable = result.findIndex((m) => m.role !== "system" && !m.meta?.pinned);
64
- if (firstDroppable === -1 || firstDroppable >= result.length - keepLast)
92
+ // Score all droppable messages and remove the lowest-scored
93
+ let lowestScore = Infinity;
94
+ let lowestIdx = -1;
95
+ for (let i = 0; i < result.length - keepLast; i++) {
96
+ const msg = result[i];
97
+ if (msg.role === 'system' || msg.meta?.pinned)
98
+ continue;
99
+ const score = scoreMessage(msg, i, result.length);
100
+ if (score < lowestScore) {
101
+ lowestScore = score;
102
+ lowestIdx = i;
103
+ }
104
+ }
105
+ if (lowestIdx === -1)
65
106
  break;
66
- result.splice(firstDroppable, 1);
107
+ result.splice(lowestIdx, 1);
67
108
  }
68
109
  // Phase 3: Remove orphaned tool results
69
110
  const validCallIds = new Set();
@@ -0,0 +1,25 @@
1
+ /**
2
+ * API security layer — auth, rate limiting, and tool access control
3
+ * for the remote server.
4
+ */
5
+ import type { IncomingMessage, ServerResponse } from 'node:http';
6
+ import type { Tools } from '../Tool.js';
7
+ /** Check if a request is within rate limits. Returns true if allowed. */
8
+ export declare function checkRateLimit(ip: string, maxPerMinute: number): boolean;
9
+ /** Validate a bearer token against configured tokens. Returns true if valid. */
10
+ export declare function validateToken(authHeader: string | undefined): boolean;
11
+ /** Filter tools based on remote config allowlist */
12
+ export declare function filterRemoteTools(tools: Tools): Tools;
13
+ /** Generate a unique request ID */
14
+ export declare function generateRequestId(): string;
15
+ export type AuthResult = {
16
+ allowed: boolean;
17
+ reason?: string;
18
+ requestId: string;
19
+ };
20
+ /**
21
+ * Run auth checks on an incoming request.
22
+ * Returns allowed=true if the request should proceed.
23
+ */
24
+ export declare function authenticateRequest(req: IncomingMessage, res: ServerResponse): AuthResult;
25
+ //# sourceMappingURL=auth.d.ts.map
@@ -0,0 +1,73 @@
1
+ /**
2
+ * API security layer — auth, rate limiting, and tool access control
3
+ * for the remote server.
4
+ */
5
+ import { readOhConfig } from '../harness/config.js';
6
+ const rateLimitMap = new Map();
7
+ const WINDOW_MS = 60_000; // 1 minute sliding window
8
+ /** Check if a request is within rate limits. Returns true if allowed. */
9
+ export function checkRateLimit(ip, maxPerMinute) {
10
+ const now = Date.now();
11
+ const entry = rateLimitMap.get(ip);
12
+ if (!entry || now - entry.windowStart > WINDOW_MS) {
13
+ rateLimitMap.set(ip, { count: 1, windowStart: now });
14
+ return true;
15
+ }
16
+ if (entry.count >= maxPerMinute)
17
+ return false;
18
+ entry.count++;
19
+ return true;
20
+ }
21
+ // ── Token Auth ──
22
+ /** Validate a bearer token against configured tokens. Returns true if valid. */
23
+ export function validateToken(authHeader) {
24
+ const config = readOhConfig();
25
+ const tokens = config?.remote?.tokens;
26
+ // No tokens configured = no auth required (open access)
27
+ if (!tokens || tokens.length === 0)
28
+ return true;
29
+ if (!authHeader)
30
+ return false;
31
+ const token = authHeader.replace(/^Bearer\s+/i, '').trim();
32
+ if (!token)
33
+ return false;
34
+ return tokens.includes(token);
35
+ }
36
+ // ── Tool Filtering ──
37
+ /** Filter tools based on remote config allowlist */
38
+ export function filterRemoteTools(tools) {
39
+ const config = readOhConfig();
40
+ const allowed = config?.remote?.allowedTools;
41
+ if (!allowed || allowed.length === 0)
42
+ return tools;
43
+ const allowSet = new Set(allowed.map(n => n.toLowerCase()));
44
+ const filtered = tools.filter(t => allowSet.has(t.name.toLowerCase()));
45
+ return filtered.length > 0 ? filtered : tools; // fallback to all if filter empties
46
+ }
47
+ // ── Request ID ──
48
+ let requestCounter = 0;
49
+ /** Generate a unique request ID */
50
+ export function generateRequestId() {
51
+ return `req-${Date.now().toString(36)}-${(++requestCounter).toString(36)}`;
52
+ }
53
+ /**
54
+ * Run auth checks on an incoming request.
55
+ * Returns allowed=true if the request should proceed.
56
+ */
57
+ export function authenticateRequest(req, res) {
58
+ const requestId = generateRequestId();
59
+ res.setHeader('X-Request-ID', requestId);
60
+ // Token auth
61
+ if (!validateToken(req.headers.authorization)) {
62
+ return { allowed: false, reason: 'Invalid or missing bearer token', requestId };
63
+ }
64
+ // Rate limiting
65
+ const config = readOhConfig();
66
+ const rateLimit = config?.remote?.rateLimit ?? 60; // default 60/min
67
+ const ip = req.socket.remoteAddress ?? 'unknown';
68
+ if (!checkRateLimit(ip, rateLimit)) {
69
+ return { allowed: false, reason: 'Rate limit exceeded', requestId };
70
+ }
71
+ return { allowed: true, requestId };
72
+ }
73
+ //# sourceMappingURL=auth.js.map