@panchr/tyr 0.1.0 → 0.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.
- package/README.md +53 -69
- package/package.json +9 -2
- package/src/cache.ts +1 -0
- package/src/commands/judge.ts +76 -20
- package/src/commands/log.ts +9 -2
- package/src/commands/suggest.ts +85 -82
- package/src/config.ts +1 -0
- package/src/index.ts +0 -0
- package/src/prompts.ts +46 -8
- package/src/providers/claude.ts +14 -1
- package/src/providers/openrouter.ts +14 -1
- package/src/transcript.ts +97 -0
- package/src/types.ts +2 -0
package/README.md
CHANGED
|
@@ -1,69 +1,63 @@
|
|
|
1
1
|
# tyr
|
|
2
2
|
|
|
3
|
-
> **Experimental** — tyr is under active development. The API, configuration schema, and CLI interface may change without notice.
|
|
3
|
+
> **Experimental** — tyr is under active development. The API, configuration schema, and CLI interface may change without notice. If upgrading between minor versions, it's highly possible that a previous configuration needs some manual update.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Tyr is a CLI for intelligently managing permissions for [Claude Code](https://docs.anthropic.com/en/docs/claude-code). It is added as a hook on `PermissionRequest` events and evaluates them against configured allow/deny patterns. In the standard mode, this evaluation is done by Claude (or another LLM) by comparing the requested Bash command to the user's configuration. The hook will auto-approve commands that fuzzily match the configuration, without manual intervention.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
The goal is to reduce the number of permission prompts sent to the user. As of now, Tyr only evaluates `Bash` tool requests; it abstains on all other tools.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
Tyr registers itself as a Claude Code [hook](https://docs.anthropic.com/en/docs/claude-code/hooks) on the `PermissionRequest` event. When Claude Code asks to run a shell command, tyr:
|
|
12
|
-
|
|
13
|
-
1. Reads your Claude Code allow/deny permission patterns
|
|
14
|
-
2. Parses compound commands (e.g. `git add . && git commit`) and checks each component
|
|
15
|
-
3. Optionally asks an LLM to evaluate ambiguous commands against your patterns
|
|
16
|
-
4. Returns allow/deny/abstain back to Claude Code
|
|
9
|
+
It is named after the [Norse god of justice](https://en.wikipedia.org/wiki/T%C3%BDr).
|
|
17
10
|
|
|
18
11
|
## Why tyr?
|
|
19
12
|
|
|
20
|
-
Claude Code's `--dangerously-skip-permissions` flag gives the agent full autonomy
|
|
13
|
+
Claude Code's `--dangerously-skip-permissions` flag gives the agent full autonomy -- it can run any command without asking. That's risky: a single bad tool call can delete files, leak secrets, or break your environment.
|
|
21
14
|
|
|
22
|
-
Tyr gives
|
|
15
|
+
Tyr gives a configurable degree of autonomy to Claude:
|
|
23
16
|
|
|
24
17
|
| Mode | What happens | Use case |
|
|
25
18
|
|------|-------------|----------|
|
|
26
|
-
| **Audit** (`tyr install --audit`) | Logs every permission request without evaluating it | Understand what Claude Code is doing
|
|
27
|
-
| **Shadow** (`tyr install --shadow`) | Runs the full allow/deny pipeline but always abstains to Claude Code | Validate your rules against real
|
|
19
|
+
| **Audit** (`tyr install --audit`) | Logs every permission request without evaluating it | Understand what Claude Code is doing without performing any of tyr's logic on the request |
|
|
20
|
+
| **Shadow** (`tyr install --shadow`) | Runs the full allow/deny pipeline but always abstains to Claude Code | Validate your rules against real requests, before an impact |
|
|
28
21
|
| **Active** (`tyr install`) | Evaluates requests and enforces allow/deny decisions | Full automation with pattern-based guardrails |
|
|
29
22
|
|
|
30
23
|
Every decision is logged to a SQLite database, so you can review what was allowed, denied, or abstained — and why.
|
|
31
24
|
|
|
32
|
-
##
|
|
33
|
-
|
|
34
|
-
- [Bun](https://bun.sh) runtime
|
|
35
|
-
- Claude Code (for integration — tyr can be tested standalone)
|
|
25
|
+
## Quickstart
|
|
36
26
|
|
|
37
|
-
|
|
27
|
+
Requires [Bun](https://bun.sh) and [Claude Code](https://docs.anthropic.com/en/docs/claude-code).
|
|
38
28
|
|
|
39
29
|
```bash
|
|
40
|
-
#
|
|
41
|
-
|
|
42
|
-
bun install
|
|
30
|
+
# Install tyr
|
|
31
|
+
bun install -g @panchr/tyr
|
|
43
32
|
|
|
44
|
-
#
|
|
45
|
-
bun run build
|
|
46
|
-
|
|
47
|
-
# Register the hook in your project (writes to .claude/settings.json)
|
|
33
|
+
# Register the hook (run this inside your project directory)
|
|
48
34
|
tyr install
|
|
49
35
|
|
|
50
|
-
#
|
|
36
|
+
# Start a Claude Code session and work as usual — tyr runs automatically.
|
|
37
|
+
# When you're done, review what happened:
|
|
38
|
+
tyr log
|
|
39
|
+
tyr stats
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
That's it. Tyr evaluates every permission request against your Claude Code allow/deny patterns and logs the result. Commands that match an allowed pattern are auto-approved; everything else falls through to Claude Code's normal prompt.
|
|
43
|
+
|
|
44
|
+
To install globally (applies to all projects):
|
|
45
|
+
|
|
46
|
+
```bash
|
|
51
47
|
tyr install --global
|
|
52
48
|
```
|
|
53
49
|
|
|
54
50
|
To remove:
|
|
55
51
|
|
|
56
52
|
```bash
|
|
57
|
-
tyr uninstall
|
|
58
|
-
tyr uninstall --global
|
|
53
|
+
tyr uninstall # project
|
|
54
|
+
tyr uninstall --global # global
|
|
59
55
|
```
|
|
60
56
|
|
|
61
57
|
Use `--dry-run` with either command to preview changes without modifying anything.
|
|
62
58
|
|
|
63
59
|
## Usage
|
|
64
60
|
|
|
65
|
-
Once installed, tyr runs automatically as a Claude Code hook. No manual invocation needed.
|
|
66
|
-
|
|
67
61
|
### Commands
|
|
68
62
|
|
|
69
63
|
```
|
|
@@ -79,7 +73,7 @@ tyr log [--last N] [--json] [--since T] [--until T] [--decision D] [--provider P
|
|
|
79
73
|
tyr log clear
|
|
80
74
|
tyr db migrate
|
|
81
75
|
tyr stats [--since T] [--json]
|
|
82
|
-
tyr suggest [--
|
|
76
|
+
tyr suggest [--global|--project] [--min-count N] [--all]
|
|
83
77
|
tyr debug claude-config [--cwd C]
|
|
84
78
|
tyr version
|
|
85
79
|
```
|
|
@@ -90,7 +84,7 @@ Tyr reads its own config from `~/.config/tyr/config.json` (overridable via `TYR_
|
|
|
90
84
|
|
|
91
85
|
| Key | Type | Default | Description |
|
|
92
86
|
|-----|------|---------|-------------|
|
|
93
|
-
| `providers` | string[] | `["chained-commands"]` | Ordered list of providers to run |
|
|
87
|
+
| `providers` | string[] | `["chained-commands", "claude"]` | Ordered list of providers to run |
|
|
94
88
|
| `failOpen` | boolean | `false` | Approve on error instead of failing closed |
|
|
95
89
|
| `claude.model` | string | `"haiku"` | Model identifier for the Claude CLI |
|
|
96
90
|
| `claude.timeout` | number | `10` | Claude request timeout in seconds |
|
|
@@ -99,6 +93,7 @@ Tyr reads its own config from `~/.config/tyr/config.json` (overridable via `TYR_
|
|
|
99
93
|
| `openrouter.endpoint` | string | `"https://openrouter.ai/api/v1"` | OpenRouter API endpoint |
|
|
100
94
|
| `openrouter.timeout` | number | `10` | OpenRouter request timeout in seconds |
|
|
101
95
|
| `openrouter.canDeny` | boolean | `false` | Whether OpenRouter can deny requests |
|
|
96
|
+
| `conversationContext` | boolean | `false` | Give LLM providers recent conversation context to judge intent (can allow commands beyond configured patterns) |
|
|
102
97
|
| `verboseLog` | boolean | `false` | Include LLM prompt/params in log entries |
|
|
103
98
|
| `logRetention` | string | `"30d"` | Auto-prune logs older than this (`"0"` to disable) |
|
|
104
99
|
|
|
@@ -123,11 +118,11 @@ Existing process environment variables take precedence over `.env` values.
|
|
|
123
118
|
|
|
124
119
|
### Providers
|
|
125
120
|
|
|
126
|
-
Tyr uses a **pipeline architecture** where providers are evaluated in sequence. The first provider to return a definitive `allow` or `deny` wins
|
|
121
|
+
Tyr uses a **pipeline architecture** where providers are evaluated in sequence. The first provider to return a definitive `allow` or `deny` wins --- remaining providers are skipped. If all providers `abstain`, the request falls through to Claude Code's default behavior (prompting the user), unless `failOpen` is `true`, in which case the request is approved.
|
|
127
122
|
|
|
128
|
-
Configure the pipeline via the `providers` array. **Order matters**
|
|
123
|
+
Configure the pipeline via the `providers` array. **Order matters** -- providers run in order.
|
|
129
124
|
|
|
130
|
-
Valid
|
|
125
|
+
Valid providers are listed below.
|
|
131
126
|
|
|
132
127
|
#### `cache`
|
|
133
128
|
|
|
@@ -137,27 +132,27 @@ Caches prior decisions in SQLite. If the same command was previously allowed or
|
|
|
137
132
|
|
|
138
133
|
#### `chained-commands`
|
|
139
134
|
|
|
140
|
-
Parses compound shell commands (`&&`, `||`, `|`, `;`, subshells, command substitution) and checks each sub-command against your Claude Code allow/deny permission patterns
|
|
135
|
+
Parses compound shell commands (`&&`, `||`, `|`, `;`, subshells, command substitution) and checks each sub-command against your Claude Code allow/deny permission patterns.
|
|
141
136
|
|
|
142
137
|
- **Allow:** All sub-commands match an allow pattern
|
|
143
|
-
- **Deny:**
|
|
138
|
+
- **Deny:** _Any_ sub-command matches a deny pattern
|
|
144
139
|
- **Abstain:** Any sub-command has no matching pattern
|
|
145
140
|
|
|
146
|
-
Only evaluates `Bash` tool requests; abstains on all other tools.
|
|
147
|
-
|
|
148
141
|
#### `claude`
|
|
149
142
|
|
|
150
|
-
Sends ambiguous commands to
|
|
143
|
+
Sends ambiguous commands to the local Claude CLI for semantic evaluation. The LLM sees your permission rules, the command, and the working directory, then reasons about whether the command is safe.
|
|
151
144
|
|
|
152
|
-
When `claude.canDeny` is `false` (the default), the LLM can only approve commands
|
|
145
|
+
When `claude.canDeny` is `false` (the default), the LLM can only approve commands -- deny decisions are converted to abstain, forcing the user to decide. Set `canDeny: true` for stricter enforcement.
|
|
153
146
|
|
|
154
|
-
|
|
147
|
+
When `conversationContext` is enabled, the LLM also sees recent conversation messages from the Claude Code session. This lets it allow commands that don't match any configured pattern if the user clearly requested the action and it's a typical, safe development command. The deny list is always checked first -- no amount of context overrides a denied pattern.
|
|
155
148
|
|
|
156
|
-
|
|
149
|
+
Requires a local `claude` CLI binary. While this is somewhat slow due to the subprocess required to run `claude` (generally a decision is made in about 5 seconds), this slowness is acceptable given that it will still be faster than a human understanding and approving a command.
|
|
150
|
+
|
|
151
|
+
The main benefit of this provider is that it reuses the authentication that `claude` is already configured with, whether that's an Anthropic API key or a subscription.
|
|
157
152
|
|
|
158
153
|
#### `openrouter`
|
|
159
154
|
|
|
160
|
-
Sends ambiguous commands to the OpenRouter API for evaluation. Same semantics as the `claude` provider but uses an HTTP API instead of the local CLI.
|
|
155
|
+
Sends ambiguous commands to the OpenRouter API for evaluation. Same semantics as the `claude` provider but uses an HTTP API instead of the local CLI. Supports `conversationContext` in the same way.
|
|
161
156
|
|
|
162
157
|
Requires `OPENROUTER_API_KEY` set in your environment or `.env` file.
|
|
163
158
|
|
|
@@ -166,13 +161,13 @@ Only evaluates `Bash` tool requests; abstains on all other tools.
|
|
|
166
161
|
#### Pipeline examples
|
|
167
162
|
|
|
168
163
|
```jsonc
|
|
169
|
-
// Safe & fast (default)
|
|
164
|
+
// Safe & fast (default) -- pattern matching only
|
|
170
165
|
{ "providers": ["chained-commands"] }
|
|
171
166
|
|
|
172
|
-
// With caching
|
|
167
|
+
// With caching -- faster repeated evaluations
|
|
173
168
|
{ "providers": ["cache", "chained-commands"] }
|
|
174
169
|
|
|
175
|
-
// Full pipeline
|
|
170
|
+
// Full pipeline -- patterns first, then Claude for ambiguous commands
|
|
176
171
|
{ "providers": ["cache", "chained-commands", "claude"] }
|
|
177
172
|
|
|
178
173
|
// Using OpenRouter instead of local Claude
|
|
@@ -231,37 +226,26 @@ Shows: total checks, decision breakdown (allow/deny/abstain/error), cache hit ra
|
|
|
231
226
|
|
|
232
227
|
### Suggestions
|
|
233
228
|
|
|
234
|
-
Tyr can analyze your decision history and
|
|
229
|
+
Tyr can analyze your decision history and start an interactive Claude session to help you refine and apply allow rules:
|
|
235
230
|
|
|
236
231
|
```bash
|
|
237
|
-
#
|
|
232
|
+
# Start an interactive session with suggested rules (commands approved >= 5 times)
|
|
238
233
|
tyr suggest
|
|
239
234
|
|
|
240
|
-
# Lower the threshold
|
|
235
|
+
# Lower the threshold for which commands are surfaced
|
|
241
236
|
tyr suggest --min-count 3
|
|
242
237
|
|
|
243
|
-
#
|
|
244
|
-
tyr suggest --
|
|
245
|
-
|
|
246
|
-
# Apply to project settings
|
|
247
|
-
tyr suggest --apply --project
|
|
248
|
-
```
|
|
249
|
-
|
|
250
|
-
### Debugging
|
|
251
|
-
|
|
252
|
-
```bash
|
|
253
|
-
# Print the merged Claude Code permission config for the current project
|
|
254
|
-
tyr debug claude-config
|
|
255
|
-
|
|
256
|
-
# Print for a different project directory
|
|
257
|
-
tyr debug claude-config --cwd /path/to/project
|
|
238
|
+
# Target project settings instead of global
|
|
239
|
+
tyr suggest --project
|
|
258
240
|
|
|
259
|
-
#
|
|
260
|
-
tyr
|
|
241
|
+
# Include commands from all projects, not just the current directory
|
|
242
|
+
tyr suggest --all
|
|
261
243
|
```
|
|
262
244
|
|
|
263
245
|
### Database migrations
|
|
264
246
|
|
|
247
|
+
When upgrading from one `tyr` version to another, run
|
|
248
|
+
|
|
265
249
|
```bash
|
|
266
250
|
# Run pending schema migrations
|
|
267
251
|
tyr db migrate
|
package/package.json
CHANGED
|
@@ -3,14 +3,21 @@
|
|
|
3
3
|
"description": "Intelligent permission management for Claude Code hooks",
|
|
4
4
|
"module": "src/index.ts",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.2.0",
|
|
7
7
|
"bin": {
|
|
8
8
|
"tyr": "src/index.ts"
|
|
9
9
|
},
|
|
10
|
-
"files": [
|
|
10
|
+
"files": [
|
|
11
|
+
"src/",
|
|
12
|
+
"!src/__tests__/"
|
|
13
|
+
],
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
11
17
|
"scripts": {
|
|
12
18
|
"start": "bun run src/index.ts",
|
|
13
19
|
"build": "bun run scripts/build.ts",
|
|
20
|
+
"publish": "npm publish",
|
|
14
21
|
"lint": "biome check src/",
|
|
15
22
|
"lint:fix": "biome check --write src/",
|
|
16
23
|
"test": "bun test",
|
package/src/cache.ts
CHANGED
|
@@ -26,6 +26,7 @@ export function computeConfigHash(
|
|
|
26
26
|
"claude.canDeny": config.claude.canDeny,
|
|
27
27
|
"openrouter.model": config.openrouter.model,
|
|
28
28
|
"openrouter.canDeny": config.openrouter.canDeny,
|
|
29
|
+
conversationContext: config.conversationContext,
|
|
29
30
|
});
|
|
30
31
|
return createHash("sha256").update(data).digest("hex");
|
|
31
32
|
}
|
package/src/commands/judge.ts
CHANGED
|
@@ -18,9 +18,12 @@ import { CacheProvider } from "../providers/cache.ts";
|
|
|
18
18
|
import { ChainedCommandsProvider } from "../providers/chained-commands.ts";
|
|
19
19
|
import { ClaudeProvider } from "../providers/claude.ts";
|
|
20
20
|
import { OpenRouterProvider } from "../providers/openrouter.ts";
|
|
21
|
+
import { formatTranscriptForPrompt, readTranscript } from "../transcript.ts";
|
|
21
22
|
import type { HookResponse, Provider, TyrConfig } from "../types.ts";
|
|
22
23
|
import { resolveProviders } from "../types.ts";
|
|
23
24
|
|
|
25
|
+
const MAX_TRANSCRIPT_MESSAGES = 10;
|
|
26
|
+
|
|
24
27
|
const judgeArgs = {
|
|
25
28
|
verbose: {
|
|
26
29
|
type: "boolean" as const,
|
|
@@ -77,6 +80,11 @@ const judgeArgs = {
|
|
|
77
80
|
type: "boolean" as const,
|
|
78
81
|
description: "Include LLM prompt and parameters in log entries",
|
|
79
82
|
},
|
|
83
|
+
"conversation-context": {
|
|
84
|
+
type: "boolean" as const,
|
|
85
|
+
description:
|
|
86
|
+
"Override conversationContext config (include transcript in LLM prompts)",
|
|
87
|
+
},
|
|
80
88
|
};
|
|
81
89
|
|
|
82
90
|
export default defineCommand({
|
|
@@ -99,6 +107,18 @@ export default defineCommand({
|
|
|
99
107
|
|
|
100
108
|
const startTime = performance.now();
|
|
101
109
|
|
|
110
|
+
// Validate config early so broken config is caught before stdin parsing
|
|
111
|
+
let config: TyrConfig;
|
|
112
|
+
try {
|
|
113
|
+
config = await readConfig();
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.error(
|
|
116
|
+
`[tyr] invalid config: ${err instanceof Error ? err.message : err}`,
|
|
117
|
+
);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
102
122
|
let raw: string;
|
|
103
123
|
try {
|
|
104
124
|
raw = await readStdin();
|
|
@@ -154,8 +174,7 @@ export default defineCommand({
|
|
|
154
174
|
if (verbose) console.error("[tyr] failed to write log:", err);
|
|
155
175
|
}
|
|
156
176
|
try {
|
|
157
|
-
|
|
158
|
-
truncateOldLogs(auditConfig.logRetention);
|
|
177
|
+
truncateOldLogs(config.logRetention);
|
|
159
178
|
} catch {
|
|
160
179
|
// best-effort
|
|
161
180
|
}
|
|
@@ -169,18 +188,6 @@ export default defineCommand({
|
|
|
169
188
|
|
|
170
189
|
// Load env vars from tyr config directory (e.g. API keys)
|
|
171
190
|
loadEnvFile();
|
|
172
|
-
|
|
173
|
-
// Build provider pipeline based on config, applying CLI overrides
|
|
174
|
-
let config: TyrConfig;
|
|
175
|
-
try {
|
|
176
|
-
config = await readConfig();
|
|
177
|
-
} catch (err) {
|
|
178
|
-
console.error(
|
|
179
|
-
`[tyr] invalid config: ${err instanceof Error ? err.message : err}`,
|
|
180
|
-
);
|
|
181
|
-
process.exit(1);
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
191
|
if (args.providers !== undefined) {
|
|
185
192
|
const parsed = parseValue("providers", args.providers);
|
|
186
193
|
if (!parsed) {
|
|
@@ -225,6 +232,8 @@ export default defineCommand({
|
|
|
225
232
|
config.openrouter.canDeny = args["openrouter-can-deny"];
|
|
226
233
|
if (args["verbose-log"] !== undefined)
|
|
227
234
|
config.verboseLog = args["verbose-log"];
|
|
235
|
+
if (args["conversation-context"] !== undefined)
|
|
236
|
+
config.conversationContext = args["conversation-context"];
|
|
228
237
|
|
|
229
238
|
const agent = new ClaudeAgent();
|
|
230
239
|
try {
|
|
@@ -233,6 +242,21 @@ export default defineCommand({
|
|
|
233
242
|
if (verbose) console.error("[tyr] failed to init agent config:", err);
|
|
234
243
|
}
|
|
235
244
|
|
|
245
|
+
// Read conversation context from transcript if enabled
|
|
246
|
+
let transcriptContext: string | undefined;
|
|
247
|
+
if (config.conversationContext) {
|
|
248
|
+
const messages = await readTranscript(
|
|
249
|
+
req.transcript_path,
|
|
250
|
+
MAX_TRANSCRIPT_MESSAGES,
|
|
251
|
+
);
|
|
252
|
+
transcriptContext = formatTranscriptForPrompt(messages) || undefined;
|
|
253
|
+
if (verbose && transcriptContext) {
|
|
254
|
+
console.error(
|
|
255
|
+
`[tyr] conversation context (${messages.length} messages):\n${transcriptContext}`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
236
260
|
// Build provider pipeline from config
|
|
237
261
|
const providers: Provider[] = [];
|
|
238
262
|
let cacheProvider: CacheProvider | null = null;
|
|
@@ -249,11 +273,23 @@ export default defineCommand({
|
|
|
249
273
|
providers.push(new ChainedCommandsProvider(agent, verbose));
|
|
250
274
|
break;
|
|
251
275
|
case "claude":
|
|
252
|
-
providers.push(
|
|
276
|
+
providers.push(
|
|
277
|
+
new ClaudeProvider(
|
|
278
|
+
agent,
|
|
279
|
+
config.claude,
|
|
280
|
+
verbose,
|
|
281
|
+
transcriptContext,
|
|
282
|
+
),
|
|
283
|
+
);
|
|
253
284
|
break;
|
|
254
285
|
case "openrouter":
|
|
255
286
|
providers.push(
|
|
256
|
-
new OpenRouterProvider(
|
|
287
|
+
new OpenRouterProvider(
|
|
288
|
+
agent,
|
|
289
|
+
config.openrouter,
|
|
290
|
+
verbose,
|
|
291
|
+
transcriptContext,
|
|
292
|
+
),
|
|
257
293
|
);
|
|
258
294
|
break;
|
|
259
295
|
}
|
|
@@ -320,12 +356,22 @@ export default defineCommand({
|
|
|
320
356
|
if (config.verboseLog) {
|
|
321
357
|
if (result.provider === "claude") {
|
|
322
358
|
llm = {
|
|
323
|
-
prompt: buildPrompt(
|
|
359
|
+
prompt: buildPrompt(
|
|
360
|
+
req,
|
|
361
|
+
agent,
|
|
362
|
+
config.claude.canDeny,
|
|
363
|
+
transcriptContext,
|
|
364
|
+
),
|
|
324
365
|
model: config.claude.model,
|
|
325
366
|
};
|
|
326
367
|
} else if (result.provider === "openrouter") {
|
|
327
368
|
llm = {
|
|
328
|
-
prompt: buildPrompt(
|
|
369
|
+
prompt: buildPrompt(
|
|
370
|
+
req,
|
|
371
|
+
agent,
|
|
372
|
+
config.openrouter.canDeny,
|
|
373
|
+
transcriptContext,
|
|
374
|
+
),
|
|
329
375
|
model: config.openrouter.model,
|
|
330
376
|
};
|
|
331
377
|
} else {
|
|
@@ -333,14 +379,24 @@ export default defineCommand({
|
|
|
333
379
|
for (const name of resolveProviders(config)) {
|
|
334
380
|
if (name === "claude") {
|
|
335
381
|
llm = {
|
|
336
|
-
prompt: buildPrompt(
|
|
382
|
+
prompt: buildPrompt(
|
|
383
|
+
req,
|
|
384
|
+
agent,
|
|
385
|
+
config.claude.canDeny,
|
|
386
|
+
transcriptContext,
|
|
387
|
+
),
|
|
337
388
|
model: config.claude.model,
|
|
338
389
|
};
|
|
339
390
|
break;
|
|
340
391
|
}
|
|
341
392
|
if (name === "openrouter") {
|
|
342
393
|
llm = {
|
|
343
|
-
prompt: buildPrompt(
|
|
394
|
+
prompt: buildPrompt(
|
|
395
|
+
req,
|
|
396
|
+
agent,
|
|
397
|
+
config.openrouter.canDeny,
|
|
398
|
+
transcriptContext,
|
|
399
|
+
),
|
|
344
400
|
model: config.openrouter.model,
|
|
345
401
|
};
|
|
346
402
|
break;
|
package/src/commands/log.ts
CHANGED
|
@@ -72,7 +72,11 @@ const logArgs = {
|
|
|
72
72
|
},
|
|
73
73
|
cwd: {
|
|
74
74
|
type: "string" as const,
|
|
75
|
-
description: "Filter by cwd path prefix",
|
|
75
|
+
description: "Filter by cwd path prefix (default: current directory)",
|
|
76
|
+
},
|
|
77
|
+
all: {
|
|
78
|
+
type: "boolean" as const,
|
|
79
|
+
description: "Show entries from all projects (default: current directory)",
|
|
76
80
|
},
|
|
77
81
|
};
|
|
78
82
|
|
|
@@ -140,13 +144,16 @@ export default defineCommand({
|
|
|
140
144
|
// Best-effort: don't fail if config is unreadable
|
|
141
145
|
}
|
|
142
146
|
|
|
147
|
+
// Default to current directory unless --all or --cwd is provided
|
|
148
|
+
const cwdFilter = args.all ? undefined : (args.cwd ?? process.cwd());
|
|
149
|
+
|
|
143
150
|
const entries = readLogEntries({
|
|
144
151
|
last: last > 0 ? last : undefined,
|
|
145
152
|
since,
|
|
146
153
|
until,
|
|
147
154
|
decision: args.decision,
|
|
148
155
|
provider: args.provider,
|
|
149
|
-
cwd:
|
|
156
|
+
cwd: cwdFilter,
|
|
150
157
|
});
|
|
151
158
|
|
|
152
159
|
const verboseMode = args.verbose ?? false;
|
package/src/commands/suggest.ts
CHANGED
|
@@ -8,13 +8,9 @@ import {
|
|
|
8
8
|
} from "../agents/claude.ts";
|
|
9
9
|
import { rejectUnknownArgs } from "../args.ts";
|
|
10
10
|
import { closeDb, getDb } from "../db.ts";
|
|
11
|
-
import { readSettings
|
|
11
|
+
import { readSettings } from "../install.ts";
|
|
12
12
|
|
|
13
13
|
const suggestArgs = {
|
|
14
|
-
apply: {
|
|
15
|
-
type: "boolean" as const,
|
|
16
|
-
description: "Write suggestions into Claude's settings.json",
|
|
17
|
-
},
|
|
18
14
|
global: {
|
|
19
15
|
type: "boolean" as const,
|
|
20
16
|
description: "Target global (~/.claude/settings.json)",
|
|
@@ -27,9 +23,10 @@ const suggestArgs = {
|
|
|
27
23
|
type: "string" as const,
|
|
28
24
|
description: "Minimum approval count to suggest (default: 5)",
|
|
29
25
|
},
|
|
30
|
-
|
|
26
|
+
all: {
|
|
31
27
|
type: "boolean" as const,
|
|
32
|
-
description:
|
|
28
|
+
description:
|
|
29
|
+
"Include commands from all projects (default: current directory)",
|
|
33
30
|
},
|
|
34
31
|
};
|
|
35
32
|
|
|
@@ -44,23 +41,39 @@ export interface Suggestion {
|
|
|
44
41
|
rule: string;
|
|
45
42
|
}
|
|
46
43
|
|
|
47
|
-
/** Query frequently-allowed commands and filter out those already in allow lists.
|
|
44
|
+
/** Query frequently-allowed commands and filter out those already in allow lists.
|
|
45
|
+
* When `cwd` is provided, only includes commands from that directory (or subdirs). */
|
|
48
46
|
export function getSuggestions(
|
|
49
47
|
minCount: number,
|
|
50
48
|
allowPatterns: string[],
|
|
49
|
+
cwd?: string,
|
|
51
50
|
): Suggestion[] {
|
|
52
51
|
const db = getDb();
|
|
53
52
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
let query: string;
|
|
54
|
+
let params: (number | string)[];
|
|
55
|
+
|
|
56
|
+
if (cwd) {
|
|
57
|
+
const escapedCwd = cwd.replace(/[%_]/g, "\\$&");
|
|
58
|
+
query = `SELECT tool_input, COUNT(*) as count
|
|
59
|
+
FROM logs
|
|
60
|
+
WHERE decision = 'allow' AND mode IS NULL AND tool_name = 'Bash'
|
|
61
|
+
AND (cwd = ? OR cwd LIKE ? || '/%' ESCAPE '\\')
|
|
62
|
+
GROUP BY tool_input
|
|
63
|
+
HAVING COUNT(*) >= ?
|
|
64
|
+
ORDER BY COUNT(*) DESC`;
|
|
65
|
+
params = [cwd, escapedCwd, minCount];
|
|
66
|
+
} else {
|
|
67
|
+
query = `SELECT tool_input, COUNT(*) as count
|
|
57
68
|
FROM logs
|
|
58
69
|
WHERE decision = 'allow' AND mode IS NULL AND tool_name = 'Bash'
|
|
59
70
|
GROUP BY tool_input
|
|
60
71
|
HAVING COUNT(*) >= ?
|
|
61
|
-
ORDER BY COUNT(*) DESC
|
|
62
|
-
|
|
63
|
-
|
|
72
|
+
ORDER BY COUNT(*) DESC`;
|
|
73
|
+
params = [minCount];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const rows = db.query(query).all(...params) as CommandFrequency[];
|
|
64
77
|
|
|
65
78
|
const suggestions: Suggestion[] = [];
|
|
66
79
|
for (const row of rows) {
|
|
@@ -79,20 +92,35 @@ export function getSuggestions(
|
|
|
79
92
|
return suggestions;
|
|
80
93
|
}
|
|
81
94
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
95
|
+
function buildSuggestSystemPrompt(
|
|
96
|
+
suggestions: Suggestion[],
|
|
97
|
+
settingsPath: string,
|
|
98
|
+
): string {
|
|
99
|
+
const commandList = suggestions
|
|
100
|
+
.map((s) => `- \`${s.command}\` (approved ${s.count} times)`)
|
|
101
|
+
.join("\n");
|
|
102
|
+
|
|
103
|
+
return `You are helping configure permission rules for Claude Code.
|
|
104
|
+
|
|
105
|
+
The user has been manually approving shell commands while using Claude Code. Tyr has identified frequently-approved commands that could be added as permanent allow rules.
|
|
90
106
|
|
|
91
|
-
|
|
92
|
-
const merged = [...existing, ...rules.filter((r) => !existingSet.has(r))];
|
|
107
|
+
## Frequently Approved Commands (not yet in allow rules)
|
|
93
108
|
|
|
94
|
-
|
|
95
|
-
|
|
109
|
+
${commandList}
|
|
110
|
+
|
|
111
|
+
## Settings File
|
|
112
|
+
- Path: ${settingsPath}
|
|
113
|
+
- Format: JSON with a \`permissions.allow\` array of strings
|
|
114
|
+
- Each rule is a string like \`Bash(pattern)\` where \`pattern\` can use \`*\` as a glob wildcard
|
|
115
|
+
- Example: \`Bash(bun *)\` allows any command starting with \`bun \`
|
|
116
|
+
|
|
117
|
+
## Your Task
|
|
118
|
+
Help the user decide which commands to add as allow rules:
|
|
119
|
+
1. Suggest generalized glob patterns that group similar commands (e.g., "bun test" and "bun lint" → "Bash(bun *)")
|
|
120
|
+
2. Explain what each pattern would match
|
|
121
|
+
3. When the user is ready, write the rules to the settings file at the path above
|
|
122
|
+
|
|
123
|
+
Be concise. Start by presenting your suggested rules and ask if the user wants to adjust them.`;
|
|
96
124
|
}
|
|
97
125
|
|
|
98
126
|
export default defineCommand({
|
|
@@ -118,67 +146,42 @@ export default defineCommand({
|
|
|
118
146
|
return;
|
|
119
147
|
}
|
|
120
148
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
| Record<string, unknown>
|
|
129
|
-
| undefined;
|
|
130
|
-
if (perms && Array.isArray(perms.allow)) {
|
|
131
|
-
allowPatterns.push(...extractBashPatterns(perms.allow));
|
|
132
|
-
}
|
|
149
|
+
const allPaths = settingsPaths(process.cwd());
|
|
150
|
+
const allowPatterns: string[] = [];
|
|
151
|
+
for (const path of allPaths) {
|
|
152
|
+
const settings = await readSettings(path);
|
|
153
|
+
const perms = settings.permissions as Record<string, unknown> | undefined;
|
|
154
|
+
if (perms && Array.isArray(perms.allow)) {
|
|
155
|
+
allowPatterns.push(...extractBashPatterns(perms.allow));
|
|
133
156
|
}
|
|
157
|
+
}
|
|
134
158
|
|
|
135
|
-
|
|
159
|
+
const cwdFilter = args.all ? undefined : process.cwd();
|
|
160
|
+
const suggestions = getSuggestions(minCount, allowPatterns, cwdFilter);
|
|
161
|
+
closeDb();
|
|
136
162
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
163
|
+
if (suggestions.length === 0) {
|
|
164
|
+
console.log("No new suggestions found.");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
141
167
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
168
|
+
const scope: "global" | "project" = args.project ? "project" : "global";
|
|
169
|
+
const configDir =
|
|
170
|
+
process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
|
|
171
|
+
const settingsPath =
|
|
172
|
+
scope === "global"
|
|
173
|
+
? join(configDir, "settings.json")
|
|
174
|
+
: join(process.cwd(), ".claude", "settings.json");
|
|
146
175
|
|
|
147
|
-
|
|
148
|
-
console.log(
|
|
149
|
-
`Suggested allow rules (commands approved >= ${minCount} times):`,
|
|
150
|
-
);
|
|
151
|
-
console.log();
|
|
152
|
-
for (const s of suggestions) {
|
|
153
|
-
console.log(` ${s.rule} (${s.count} approvals)`);
|
|
154
|
-
}
|
|
155
|
-
console.log();
|
|
156
|
-
console.log("Run with --apply to add these rules to Claude settings.");
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
176
|
+
const systemPrompt = buildSuggestSystemPrompt(suggestions, settingsPath);
|
|
159
177
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const settings = await readSettings(settingsPath);
|
|
169
|
-
|
|
170
|
-
const newRules = suggestions.map((s) => s.rule);
|
|
171
|
-
const merged = mergeAllowRules(settings, newRules);
|
|
172
|
-
await writeSettings(settingsPath, merged);
|
|
173
|
-
|
|
174
|
-
console.log(
|
|
175
|
-
`Added ${newRules.length} allow rule(s) to ${scope} settings (${settingsPath}):`,
|
|
176
|
-
);
|
|
177
|
-
for (const rule of newRules) {
|
|
178
|
-
console.log(` ${rule}`);
|
|
179
|
-
}
|
|
180
|
-
} finally {
|
|
181
|
-
closeDb();
|
|
182
|
-
}
|
|
178
|
+
const proc = Bun.spawn(["claude", "--append-system-prompt", systemPrompt], {
|
|
179
|
+
stdin: "inherit",
|
|
180
|
+
stdout: "inherit",
|
|
181
|
+
stderr: "inherit",
|
|
182
|
+
env: { ...process.env, CLAUDECODE: undefined },
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
process.exitCode = await proc.exited;
|
|
183
186
|
},
|
|
184
187
|
});
|
package/src/config.ts
CHANGED
package/src/index.ts
CHANGED
|
File without changes
|
package/src/prompts.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { PermissionRequest } from "./types.ts";
|
|
|
3
3
|
|
|
4
4
|
/** Expected shape of the LLM's JSON response. */
|
|
5
5
|
export interface LlmDecision {
|
|
6
|
-
decision: "allow" | "deny";
|
|
6
|
+
decision: "allow" | "deny" | "abstain";
|
|
7
7
|
reason: string;
|
|
8
8
|
}
|
|
9
9
|
|
|
@@ -12,20 +12,56 @@ export function buildPrompt(
|
|
|
12
12
|
req: PermissionRequest,
|
|
13
13
|
agent: ClaudeAgent,
|
|
14
14
|
canDeny: boolean,
|
|
15
|
+
conversationContext?: string,
|
|
15
16
|
): string {
|
|
16
17
|
const info = agent.getDebugInfo();
|
|
17
18
|
const command =
|
|
18
19
|
typeof req.tool_input.command === "string" ? req.tool_input.command : "";
|
|
19
20
|
|
|
20
|
-
const
|
|
21
|
-
|
|
21
|
+
const fallthrough = canDeny ? "deny" : "abstain";
|
|
22
|
+
|
|
23
|
+
const conversationSection = conversationContext
|
|
24
|
+
? `\n## Recent conversation\n${conversationContext}\n`
|
|
25
|
+
: "";
|
|
26
|
+
|
|
27
|
+
let rules: string;
|
|
28
|
+
if (conversationContext) {
|
|
29
|
+
rules = `1. If the command matches a DENIED pattern → deny. No exceptions, regardless of context.
|
|
30
|
+
2. If the command is a variation of an ALLOWED pattern → allow.
|
|
31
|
+
3. If the command matches neither pattern, allow ONLY if ALL of these are true:
|
|
32
|
+
- The user clearly requested or implied this action in the conversation
|
|
33
|
+
- It is a typical development command (build, test, lint, search, read, install, etc.)
|
|
34
|
+
- It does not access sensitive resources (credentials, .env, auth tokens)
|
|
35
|
+
- It does not make irreversible system-wide changes
|
|
36
|
+
4. Otherwise → ${fallthrough}.
|
|
37
|
+
5. Only allow commands clearly within the spirit of an existing allowed pattern OR clearly supported by conversation context.`;
|
|
38
|
+
} else if (canDeny) {
|
|
39
|
+
rules = `- If the command is a variation of one of the ALLOWED patterns → allow.
|
|
22
40
|
- If the command is a variation of one of the DENIED patterns → deny.
|
|
23
41
|
- If the command is not clearly similar to either set of patterns → deny (fail-closed).
|
|
24
|
-
- Only allow commands that are clearly within the spirit of an existing allowed pattern
|
|
25
|
-
|
|
42
|
+
- Only allow commands that are clearly within the spirit of an existing allowed pattern.`;
|
|
43
|
+
} else {
|
|
44
|
+
rules = `- If the command is a variation of one of the ALLOWED patterns → allow.
|
|
26
45
|
- If the command is NOT clearly similar to an allowed pattern → abstain.
|
|
27
46
|
- Only allow commands that are clearly within the spirit of an existing allowed pattern.
|
|
28
47
|
- You CANNOT deny commands. Your only options are allow or abstain.`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const examples = conversationContext
|
|
51
|
+
? `
|
|
52
|
+
|
|
53
|
+
## Examples
|
|
54
|
+
- User: "run the tests" → agent runs \`pytest\` → allow (clear intent, common dev command)
|
|
55
|
+
- User: "check the bundle size" → agent runs \`du -sh dist/\` → allow (clear intent, read-only)
|
|
56
|
+
- User: "install the dependencies" → agent runs \`npm install\` → allow (clear intent, standard workflow)
|
|
57
|
+
- User: "format the code" → agent runs \`prettier --write src/\` → allow (clear intent, common dev tool)
|
|
58
|
+
- User: "check what's listening on port 3000" → agent runs \`lsof -i :3000\` → allow (clear intent, read-only)
|
|
59
|
+
- User: "fix the bug" → agent runs \`curl https://example.com\` → ${fallthrough} (user didn't ask for network requests)
|
|
60
|
+
- User: "deploy this" → agent runs \`rm -rf /tmp/*\` → ${fallthrough} (destructive, not clearly related)
|
|
61
|
+
- Agent runs \`cat .env\` with no relevant user message → ${fallthrough} (no clear intent, sensitive file)
|
|
62
|
+
- Agent runs \`ssh remote-host\` with no relevant context → ${fallthrough} (network access, no clear intent)
|
|
63
|
+
- User: "clean up the build" → agent runs \`rm -rf node_modules/ dist/\` → allow (clear intent, scoped to project)`
|
|
64
|
+
: "";
|
|
29
65
|
|
|
30
66
|
const responseFormat = canDeny
|
|
31
67
|
? `{"decision": "allow", "reason": "brief explanation"}
|
|
@@ -43,7 +79,7 @@ A coding assistant is requesting permission to run a shell command. Your job is
|
|
|
43
79
|
- Working directory: ${req.cwd}
|
|
44
80
|
- Tool: ${req.tool_name}
|
|
45
81
|
- Command: ${command}
|
|
46
|
-
|
|
82
|
+
${conversationSection}
|
|
47
83
|
## Configured permission patterns
|
|
48
84
|
- Allowed patterns: ${JSON.stringify(info.allow)}
|
|
49
85
|
- Denied patterns: ${JSON.stringify(info.deny)}
|
|
@@ -51,7 +87,7 @@ A coding assistant is requesting permission to run a shell command. Your job is
|
|
|
51
87
|
The command did not exactly match any pattern, so you must judge by similarity.
|
|
52
88
|
|
|
53
89
|
## Rules
|
|
54
|
-
${rules}
|
|
90
|
+
${rules}${examples}
|
|
55
91
|
|
|
56
92
|
Respond with ONLY a JSON object in this exact format, no other text:
|
|
57
93
|
${responseFormat}`;
|
|
@@ -71,7 +107,9 @@ export function parseLlmResponse(stdout: string): LlmDecision | null {
|
|
|
71
107
|
try {
|
|
72
108
|
const parsed = JSON.parse(jsonStr) as Record<string, unknown>;
|
|
73
109
|
if (
|
|
74
|
-
(parsed.decision === "allow" ||
|
|
110
|
+
(parsed.decision === "allow" ||
|
|
111
|
+
parsed.decision === "deny" ||
|
|
112
|
+
parsed.decision === "abstain") &&
|
|
75
113
|
typeof parsed.reason === "string"
|
|
76
114
|
) {
|
|
77
115
|
return { decision: parsed.decision, reason: parsed.reason };
|
package/src/providers/claude.ts
CHANGED
|
@@ -20,14 +20,18 @@ export class ClaudeProvider implements Provider {
|
|
|
20
20
|
private model: string;
|
|
21
21
|
private canDeny: boolean;
|
|
22
22
|
|
|
23
|
+
private conversationContext?: string;
|
|
24
|
+
|
|
23
25
|
constructor(
|
|
24
26
|
private agent: ClaudeAgent,
|
|
25
27
|
config: ClaudeConfig,
|
|
26
28
|
private verbose: boolean = false,
|
|
29
|
+
conversationContext?: string,
|
|
27
30
|
) {
|
|
28
31
|
this.model = config.model;
|
|
29
32
|
this.timeoutMs = config.timeout * S_TO_MS;
|
|
30
33
|
this.canDeny = config.canDeny;
|
|
34
|
+
this.conversationContext = conversationContext;
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
async checkPermission(req: PermissionRequest): Promise<ProviderResult> {
|
|
@@ -37,7 +41,12 @@ export class ClaudeProvider implements Provider {
|
|
|
37
41
|
if (typeof command !== "string" || command.trim() === "")
|
|
38
42
|
return { decision: "abstain" };
|
|
39
43
|
|
|
40
|
-
const prompt = buildPrompt(
|
|
44
|
+
const prompt = buildPrompt(
|
|
45
|
+
req,
|
|
46
|
+
this.agent,
|
|
47
|
+
this.canDeny,
|
|
48
|
+
this.conversationContext,
|
|
49
|
+
);
|
|
41
50
|
|
|
42
51
|
// Clear CLAUDECODE env var so claude -p doesn't refuse to run
|
|
43
52
|
// inside a Claude Code session (tyr is invoked as a hook).
|
|
@@ -110,6 +119,10 @@ export class ClaudeProvider implements Provider {
|
|
|
110
119
|
const llmDecision = parseLlmResponse(result.stdout);
|
|
111
120
|
if (!llmDecision) return { decision: "abstain" };
|
|
112
121
|
|
|
122
|
+
if (llmDecision.decision === "abstain") {
|
|
123
|
+
return { decision: "abstain", reason: llmDecision.reason };
|
|
124
|
+
}
|
|
125
|
+
|
|
113
126
|
// When canDeny is false, convert deny→abstain so the user gets prompted
|
|
114
127
|
if (!this.canDeny && llmDecision.decision === "deny") {
|
|
115
128
|
return { decision: "abstain", reason: llmDecision.reason };
|
|
@@ -19,15 +19,19 @@ export class OpenRouterProvider implements Provider {
|
|
|
19
19
|
private endpoint: string;
|
|
20
20
|
private canDeny: boolean;
|
|
21
21
|
|
|
22
|
+
private conversationContext?: string;
|
|
23
|
+
|
|
22
24
|
constructor(
|
|
23
25
|
private agent: ClaudeAgent,
|
|
24
26
|
config: OpenRouterConfig,
|
|
25
27
|
private verbose: boolean = false,
|
|
28
|
+
conversationContext?: string,
|
|
26
29
|
) {
|
|
27
30
|
this.model = config.model;
|
|
28
31
|
this.timeoutMs = config.timeout * S_TO_MS;
|
|
29
32
|
this.canDeny = config.canDeny;
|
|
30
33
|
this.endpoint = config.endpoint;
|
|
34
|
+
this.conversationContext = conversationContext;
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
async checkPermission(req: PermissionRequest): Promise<ProviderResult> {
|
|
@@ -45,7 +49,12 @@ export class OpenRouterProvider implements Provider {
|
|
|
45
49
|
return { decision: "abstain" };
|
|
46
50
|
}
|
|
47
51
|
|
|
48
|
-
const prompt = buildPrompt(
|
|
52
|
+
const prompt = buildPrompt(
|
|
53
|
+
req,
|
|
54
|
+
this.agent,
|
|
55
|
+
this.canDeny,
|
|
56
|
+
this.conversationContext,
|
|
57
|
+
);
|
|
49
58
|
const url = `${this.endpoint}/chat/completions`;
|
|
50
59
|
|
|
51
60
|
const controller = new AbortController();
|
|
@@ -102,6 +111,10 @@ export class OpenRouterProvider implements Provider {
|
|
|
102
111
|
const llmDecision = parseLlmResponse(responseText);
|
|
103
112
|
if (!llmDecision) return { decision: "abstain" };
|
|
104
113
|
|
|
114
|
+
if (llmDecision.decision === "abstain") {
|
|
115
|
+
return { decision: "abstain", reason: llmDecision.reason };
|
|
116
|
+
}
|
|
117
|
+
|
|
105
118
|
// When canDeny is false, convert deny→abstain so the user gets prompted
|
|
106
119
|
if (!this.canDeny && llmDecision.decision === "deny") {
|
|
107
120
|
return { decision: "abstain", reason: llmDecision.reason };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
export interface TranscriptMessage {
|
|
4
|
+
role: "user" | "assistant";
|
|
5
|
+
content: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Content block within an assistant message. */
|
|
9
|
+
interface ContentBlock {
|
|
10
|
+
type: string;
|
|
11
|
+
text?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Shape of a single JSONL line in the transcript file. */
|
|
15
|
+
interface TranscriptLine {
|
|
16
|
+
type: string;
|
|
17
|
+
isSidechain?: boolean;
|
|
18
|
+
isMeta?: boolean;
|
|
19
|
+
message?: {
|
|
20
|
+
role?: string;
|
|
21
|
+
content?: string | ContentBlock[];
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Read the last N conversation messages from a Claude transcript JSONL file.
|
|
26
|
+
* Returns an empty array on any error (graceful degradation). */
|
|
27
|
+
export async function readTranscript(
|
|
28
|
+
path: string,
|
|
29
|
+
maxMessages: number,
|
|
30
|
+
): Promise<TranscriptMessage[]> {
|
|
31
|
+
let text: string;
|
|
32
|
+
try {
|
|
33
|
+
text = await readFile(path, "utf-8");
|
|
34
|
+
} catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const messages: TranscriptMessage[] = [];
|
|
39
|
+
|
|
40
|
+
for (const line of text.split("\n")) {
|
|
41
|
+
const trimmed = line.trim();
|
|
42
|
+
if (!trimmed) continue;
|
|
43
|
+
|
|
44
|
+
let entry: TranscriptLine;
|
|
45
|
+
try {
|
|
46
|
+
entry = JSON.parse(trimmed) as TranscriptLine;
|
|
47
|
+
} catch {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Skip non-conversation entries
|
|
52
|
+
if (entry.type !== "user" && entry.type !== "assistant") continue;
|
|
53
|
+
if (entry.isSidechain || entry.isMeta) continue;
|
|
54
|
+
if (!entry.message) continue;
|
|
55
|
+
|
|
56
|
+
if (entry.type === "user") {
|
|
57
|
+
if (typeof entry.message.content !== "string") continue;
|
|
58
|
+
messages.push({ role: "user", content: entry.message.content });
|
|
59
|
+
} else {
|
|
60
|
+
// Assistant: extract text content blocks
|
|
61
|
+
const content = entry.message.content;
|
|
62
|
+
if (!Array.isArray(content)) continue;
|
|
63
|
+
const textParts = content
|
|
64
|
+
.filter(
|
|
65
|
+
(block): block is ContentBlock & { text: string } =>
|
|
66
|
+
block.type === "text" && typeof block.text === "string",
|
|
67
|
+
)
|
|
68
|
+
.map((block) => block.text);
|
|
69
|
+
if (textParts.length === 0) continue;
|
|
70
|
+
messages.push({ role: "assistant", content: textParts.join("\n") });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return messages.slice(-maxMessages);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const DEFAULT_MAX_CHARS = 500;
|
|
78
|
+
|
|
79
|
+
/** Format transcript messages for inclusion in an LLM prompt.
|
|
80
|
+
* Each message is truncated to maxCharsPerMessage characters.
|
|
81
|
+
* Returns an empty string if there are no messages. */
|
|
82
|
+
export function formatTranscriptForPrompt(
|
|
83
|
+
messages: TranscriptMessage[],
|
|
84
|
+
maxCharsPerMessage: number = DEFAULT_MAX_CHARS,
|
|
85
|
+
): string {
|
|
86
|
+
if (messages.length === 0) return "";
|
|
87
|
+
|
|
88
|
+
const lines = messages.map((msg) => {
|
|
89
|
+
const truncated =
|
|
90
|
+
msg.content.length > maxCharsPerMessage
|
|
91
|
+
? `${msg.content.slice(0, maxCharsPerMessage)}...`
|
|
92
|
+
: msg.content;
|
|
93
|
+
return `[${msg.role}]: ${truncated}`;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return lines.join("\n");
|
|
97
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -87,6 +87,8 @@ export const TyrConfigSchema = z.object({
|
|
|
87
87
|
claude: ClaudeConfigSchema.default(ClaudeConfigSchema.parse({})),
|
|
88
88
|
/** OpenRouter API provider configuration. */
|
|
89
89
|
openrouter: OpenRouterConfigSchema.default(OpenRouterConfigSchema.parse({})),
|
|
90
|
+
/** Include recent conversation messages in LLM judge prompts for better context. */
|
|
91
|
+
conversationContext: z.boolean().default(false),
|
|
90
92
|
/** Include LLM prompt and parameters in log entries for debugging. */
|
|
91
93
|
verboseLog: z.boolean().default(false),
|
|
92
94
|
/** Maximum age of log entries. Entries older than this are pruned on the next tyr invocation.
|