@miphamai/cli 0.2.4 → 0.3.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/package.json +4 -3
- package/skills/mipham/om-model-optimize.mipham-skill.md +55 -9
- package/skills/mipham/om-security.mipham-skill.md +82 -10
- package/skills/standard/doc-generator.SKILL.md +67 -11
- package/skills/standard/github-ops.SKILL.md +89 -13
- package/skills/standard/memory.SKILL.md +71 -11
- package/skills/standard/self-review.SKILL.md +49 -10
- package/skills/standard/superpower.SKILL.md +46 -8
- package/skills/standard/tdd.SKILL.md +85 -12
- package/skills/standard/web-search.SKILL.md +62 -13
- package/src/core/context.ts +73 -13
- package/src/core/engine.ts +159 -44
- package/src/core/permission.ts +31 -5
- package/src/core/session-store.ts +5 -1
- package/src/index.tsx +25 -0
- package/src/providers/anthropic.ts +2 -1
- package/src/providers/fetch-utils.ts +103 -0
- package/src/providers/openai-compat.ts +55 -8
- package/src/providers/registry.ts +1 -0
- package/src/ui/app.tsx +117 -14
- package/src/ui/chat.tsx +25 -7
- package/src/ui/commands.ts +31 -9
- package/src/ui/input.tsx +56 -35
|
@@ -1,20 +1,93 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: tdd
|
|
3
|
-
description: Test-Driven Development
|
|
4
|
-
version:
|
|
3
|
+
description: Test-Driven Development — red-green-refactor cycle with language-specific guidance and test design rules
|
|
4
|
+
version: 2.0.0
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
# TDD
|
|
7
|
+
# Test-Driven Development (TDD)
|
|
8
8
|
|
|
9
|
-
##
|
|
9
|
+
## The Cycle
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
```
|
|
12
|
+
RED → GREEN → REFACTOR → repeat
|
|
13
|
+
```
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
### 1. RED — Write a Failing Test
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
17
|
+
Write the smallest test that captures the behavior you want:
|
|
18
|
+
|
|
19
|
+
- Name the test descriptively: `it('should return 0 for empty string')`
|
|
20
|
+
- Use the AAA pattern: **A**rrange → **A**ct → **A**ssert
|
|
21
|
+
- Run to confirm it **fails** (not errors — fails)
|
|
22
|
+
- If it passes before implementation, your test is wrong
|
|
23
|
+
|
|
24
|
+
### 2. GREEN — Make It Pass
|
|
25
|
+
|
|
26
|
+
Write the **minimum** code to make the test pass:
|
|
27
|
+
|
|
28
|
+
- Don't optimize, don't generalize, don't add features
|
|
29
|
+
- A hardcoded return is fine if it passes the test
|
|
30
|
+
- Run all tests — the new one should pass, old ones should still pass
|
|
31
|
+
|
|
32
|
+
### 3. REFACTOR — Clean Up
|
|
33
|
+
|
|
34
|
+
Improve the code while tests stay green:
|
|
35
|
+
|
|
36
|
+
- Remove duplication (test code and production code)
|
|
37
|
+
- Improve names, extract helpers
|
|
38
|
+
- Simplify logic
|
|
39
|
+
- Run tests after each change
|
|
40
|
+
|
|
41
|
+
## Test Design Rules
|
|
42
|
+
|
|
43
|
+
- **Deterministic**: No `Date.now()`, `Math.random()`, or network calls in test bodies
|
|
44
|
+
- **Isolated**: Each test sets up its own state; no test-order dependency
|
|
45
|
+
- **Fast**: Unit tests should run in milliseconds, not seconds
|
|
46
|
+
- **Readable**: Test output should explain what broke without reading source
|
|
47
|
+
|
|
48
|
+
## Language-Specific Guidance
|
|
49
|
+
|
|
50
|
+
### TypeScript / JavaScript (Vitest)
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { describe, it, expect } from 'vitest'
|
|
54
|
+
|
|
55
|
+
describe('sum', () => {
|
|
56
|
+
it('should add two positive numbers', () => {
|
|
57
|
+
expect(sum(2, 3)).toBe(5)
|
|
58
|
+
})
|
|
59
|
+
it('should handle zero', () => {
|
|
60
|
+
expect(sum(0, 5)).toBe(5)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
File naming: `src/foo.ts` → `test/foo.test.ts`
|
|
66
|
+
|
|
67
|
+
### Python (pytest)
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
def test_sum_positive():
|
|
71
|
+
assert sum(2, 3) == 5
|
|
72
|
+
|
|
73
|
+
def test_sum_zero():
|
|
74
|
+
assert sum(0, 5) == 5
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Go (testing package)
|
|
78
|
+
|
|
79
|
+
```go
|
|
80
|
+
func TestSumPositive(t *testing.T) {
|
|
81
|
+
got := Sum(2, 3)
|
|
82
|
+
want := 5
|
|
83
|
+
if got != want {
|
|
84
|
+
t.Errorf("Sum(2,3) = %d; want %d", got, want)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## When NOT to TDD
|
|
90
|
+
|
|
91
|
+
- Exploratory spikes (throw away after learning)
|
|
92
|
+
- Configuration files and types (compile-time enforced)
|
|
93
|
+
- Generated code
|
|
@@ -1,23 +1,72 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: web-search
|
|
3
|
-
description: Search the web for current information
|
|
4
|
-
version:
|
|
3
|
+
description: Search the web for current information — documentation, news, technical references, and troubleshooting
|
|
4
|
+
version: 2.0.0
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
# Web Search
|
|
7
|
+
# Web Search
|
|
8
8
|
|
|
9
|
-
Use WebSearch tool to find current information.
|
|
9
|
+
Use `WebSearch` tool to find current, accurate information from the web.
|
|
10
10
|
|
|
11
11
|
## When to Use
|
|
12
12
|
|
|
13
|
-
-
|
|
14
|
-
- Current events
|
|
15
|
-
-
|
|
16
|
-
-
|
|
13
|
+
- **Library/framework docs**: API references, migration guides, version-specific features
|
|
14
|
+
- **Current events**: News, releases, incidents (anything after training cutoff)
|
|
15
|
+
- **Troubleshooting**: Error messages, stack traces, known issues
|
|
16
|
+
- **Comparisons**: Technology trade-offs, benchmark data
|
|
17
|
+
- **Code examples**: Real-world usage patterns, configuration snippets
|
|
17
18
|
|
|
18
|
-
## Best Practices
|
|
19
|
+
## Query Best Practices
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
### Be Specific
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
❌ "React" → too broad
|
|
25
|
+
❌ "React problems" → ambiguous
|
|
26
|
+
✅ "React 19 useEffect double mount fix" → specific, versioned
|
|
27
|
+
✅ "Next.js 14 App Router caching behavior 2026" → targeted
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Use Technical Terms
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
❌ "how to make website fast"
|
|
34
|
+
✅ "Core Web Vitals LCP optimization Next.js 14"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Include Version Numbers
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
❌ "prisma query"
|
|
41
|
+
✅ "Prisma 5.0 findMany with nested include filter"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Domain Filtering
|
|
45
|
+
|
|
46
|
+
Use `allowed_domains` for authoritative sources:
|
|
47
|
+
- `docs.github.com` — GitHub documentation
|
|
48
|
+
- `nextjs.org` — Next.js official docs
|
|
49
|
+
- `developer.mozilla.org` — MDN Web Docs
|
|
50
|
+
- `nodejs.org` — Node.js official
|
|
51
|
+
|
|
52
|
+
## Verification
|
|
53
|
+
|
|
54
|
+
- **Cross-reference**: Verify claims across 2+ independent sources
|
|
55
|
+
- **Recency**: Prefer results from the current year; note article dates
|
|
56
|
+
- **Authority**: Official docs > well-known blogs > Stack Overflow > random forums
|
|
57
|
+
- **Cite sources**: Always include source URLs in responses
|
|
58
|
+
|
|
59
|
+
## Source Attribution
|
|
60
|
+
|
|
61
|
+
After answering from search results, end with:
|
|
62
|
+
```markdown
|
|
63
|
+
Sources:
|
|
64
|
+
- [Title](URL) — brief note
|
|
65
|
+
- [Title](URL) — brief note
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## When NOT to Search
|
|
69
|
+
|
|
70
|
+
- Pure logic or algorithmic questions (reasoning, not research)
|
|
71
|
+
- Questions answerable from code already in context
|
|
72
|
+
- Opinions and subjective recommendations (search for data, not consensus)
|
package/src/core/context.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Message } from '../shared/index.ts'
|
|
2
2
|
|
|
3
|
+
export type Summarizer = (messages: Message[], heading: string) => Promise<string>
|
|
4
|
+
|
|
3
5
|
interface ContextConfig {
|
|
4
6
|
maxTokens: number
|
|
5
7
|
compactionThreshold: number // e.g. 0.9 → compact at 90% usage
|
|
@@ -19,9 +21,15 @@ export class ContextManager {
|
|
|
19
21
|
private estimatedTokens = 0
|
|
20
22
|
private checkpoints: Checkpoint[] = []
|
|
21
23
|
private checkpointCounter = 0
|
|
24
|
+
private summarizer?: Summarizer
|
|
22
25
|
|
|
23
26
|
constructor(private config: ContextConfig) {}
|
|
24
27
|
|
|
28
|
+
/** Set an optional LLM summarizer for intelligent compaction. */
|
|
29
|
+
setSummarizer(fn: Summarizer): void {
|
|
30
|
+
this.summarizer = fn
|
|
31
|
+
}
|
|
32
|
+
|
|
25
33
|
setSystemPrompt(prompt: string): void {
|
|
26
34
|
this.systemPrompt = prompt
|
|
27
35
|
this.estimatedTokens = this.estimateTokens(prompt)
|
|
@@ -46,19 +54,36 @@ export class ContextManager {
|
|
|
46
54
|
return this.estimatedTokens > this.config.maxTokens * this.config.compactionThreshold
|
|
47
55
|
}
|
|
48
56
|
|
|
49
|
-
async compact(
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
57
|
+
async compact(heading: string): Promise<void> {
|
|
58
|
+
if (this.messages.length <= 30) return
|
|
59
|
+
|
|
60
|
+
const keep = 20
|
|
61
|
+
const toDrop = this.messages.slice(0, -keep)
|
|
62
|
+
|
|
63
|
+
if (this.summarizer && toDrop.length >= 4) {
|
|
64
|
+
// LLM-based summarization of truncated messages
|
|
65
|
+
try {
|
|
66
|
+
const summary = await this.summarizer(toDrop, heading)
|
|
67
|
+
const summaryMsg: Message = {
|
|
68
|
+
role: 'user',
|
|
69
|
+
content: `[Earlier conversation summary]: ${summary}`,
|
|
70
|
+
}
|
|
71
|
+
this.messages = [summaryMsg, ...this.messages.slice(-keep)]
|
|
72
|
+
} catch {
|
|
73
|
+
// Fall back to truncation on summarizer failure
|
|
74
|
+
this.messages = this.messages.slice(-keep)
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
// Simple truncation fallback
|
|
53
78
|
this.messages = this.messages.slice(-keep)
|
|
79
|
+
}
|
|
54
80
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
81
|
+
// Re-estimate tokens
|
|
82
|
+
this.estimatedTokens = this.estimateTokens(this.systemPrompt)
|
|
83
|
+
for (const msg of this.messages) {
|
|
84
|
+
this.estimatedTokens += this.estimateTokens(
|
|
85
|
+
typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
|
|
86
|
+
)
|
|
62
87
|
}
|
|
63
88
|
}
|
|
64
89
|
|
|
@@ -128,8 +153,43 @@ export class ContextManager {
|
|
|
128
153
|
return this.checkpoints.at(-1)?.id
|
|
129
154
|
}
|
|
130
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Estimate token count for a text string.
|
|
158
|
+
*
|
|
159
|
+
* Uses a character-class-aware heuristic:
|
|
160
|
+
* - CJK / emoji: ~1.5 chars per token
|
|
161
|
+
* - Other scripts (Latin, Cyrillic, Arabic): ~4 chars per token
|
|
162
|
+
* - Whitespace-heavy (code): ~3 chars per token
|
|
163
|
+
*
|
|
164
|
+
* This is ~30-40% more accurate than flat 4 chars/token for mixed-language text.
|
|
165
|
+
*/
|
|
131
166
|
private estimateTokens(text: string): number {
|
|
132
|
-
|
|
133
|
-
|
|
167
|
+
if (!text) return 0
|
|
168
|
+
|
|
169
|
+
let cjk = 0
|
|
170
|
+
let latin = 0
|
|
171
|
+
|
|
172
|
+
for (const ch of text) {
|
|
173
|
+
const cp = ch.codePointAt(0)!
|
|
174
|
+
// CJK Unified Ideographs, Hangul, Kana, CJK Extensions, fullwidth forms
|
|
175
|
+
if (
|
|
176
|
+
(cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified
|
|
177
|
+
(cp >= 0x3400 && cp <= 0x4dbf) || // CJK Ext-A
|
|
178
|
+
(cp >= 0x20000 && cp <= 0x2a6df) || // CJK Ext-B
|
|
179
|
+
(cp >= 0xac00 && cp <= 0xd7af) || // Hangul
|
|
180
|
+
(cp >= 0x3040 && cp <= 0x30ff) || // Hiragana + Katakana
|
|
181
|
+
(cp >= 0xff01 && cp <= 0xff60) || // Fullwidth
|
|
182
|
+
(cp >= 0x1f300 && cp <= 0x1f9ff) // Emoji / pictographs
|
|
183
|
+
) {
|
|
184
|
+
cjk++
|
|
185
|
+
} else if (cp > 0x7f) {
|
|
186
|
+
latin++
|
|
187
|
+
} else {
|
|
188
|
+
latin++
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// CJK: ~1.5 chars/token, Latin/other: ~4 chars/token
|
|
193
|
+
return Math.max(1, Math.ceil(cjk / 1.5 + latin / 4))
|
|
134
194
|
}
|
|
135
195
|
}
|
package/src/core/engine.ts
CHANGED
|
@@ -2,8 +2,11 @@ import type { Message, StreamChunk, ToolDefinition, ToolResult } from '../shared
|
|
|
2
2
|
import { ProviderRegistry } from '../providers/registry'
|
|
3
3
|
import { ContextManager } from './context'
|
|
4
4
|
import { PermissionSystem } from './permission'
|
|
5
|
+
import type { HookEngine } from './hooks'
|
|
5
6
|
|
|
6
7
|
export class QueryEngine {
|
|
8
|
+
private hookEngine?: HookEngine
|
|
9
|
+
|
|
7
10
|
constructor(
|
|
8
11
|
private registry: ProviderRegistry,
|
|
9
12
|
private context: ContextManager,
|
|
@@ -11,11 +14,56 @@ export class QueryEngine {
|
|
|
11
14
|
private permission: PermissionSystem = new PermissionSystem('bypass'),
|
|
12
15
|
) {}
|
|
13
16
|
|
|
17
|
+
/** Register a hook engine for pre/post tool-use lifecycle events. */
|
|
18
|
+
setHookEngine(hooks: HookEngine): void {
|
|
19
|
+
this.hookEngine = hooks
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Wire LLM-based conversation summarization into the context manager. */
|
|
23
|
+
setupContextSummarizer(): void {
|
|
24
|
+
this.context.setSummarizer(async (messages, heading) => {
|
|
25
|
+
const text = messages
|
|
26
|
+
.map((m) => {
|
|
27
|
+
const role = m.role
|
|
28
|
+
const content = typeof m.content === 'string' ? m.content : JSON.stringify(m.content)
|
|
29
|
+
return `[${role}]: ${content.slice(0, 500)}`
|
|
30
|
+
})
|
|
31
|
+
.join('\n')
|
|
32
|
+
|
|
33
|
+
// Build a minimal system prompt for summarization
|
|
34
|
+
const summaryPrompt =
|
|
35
|
+
`You are a conversation summarizer. Create a concise summary (1-3 paragraphs) of this conversation excerpt. Focus on: key topics discussed, decisions made, code changes mentioned, and open questions. Heading: ${heading}`
|
|
36
|
+
|
|
37
|
+
// Collect full summary text from streaming response
|
|
38
|
+
let summary = ''
|
|
39
|
+
try {
|
|
40
|
+
for await (const chunk of this.registry.chat({
|
|
41
|
+
model: this.registry.getActiveModel(),
|
|
42
|
+
messages: [
|
|
43
|
+
{ role: 'system', content: summaryPrompt },
|
|
44
|
+
{ role: 'user', content: text },
|
|
45
|
+
],
|
|
46
|
+
maxTokens: 300,
|
|
47
|
+
})) {
|
|
48
|
+
if (chunk.type === 'text' && chunk.content) {
|
|
49
|
+
summary += chunk.content
|
|
50
|
+
}
|
|
51
|
+
if (chunk.type === 'stop') break
|
|
52
|
+
if (chunk.type === 'error') break
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// Return a minimal summary on failure
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return summary.slice(0, 2000) || 'Prior conversation context omitted.'
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
14
62
|
getPermission(): PermissionSystem {
|
|
15
63
|
return this.permission
|
|
16
64
|
}
|
|
17
65
|
|
|
18
|
-
async *process(userInput: string): AsyncGenerator<StreamChunk> {
|
|
66
|
+
async *process(userInput: string, signal?: AbortSignal): AsyncGenerator<StreamChunk> {
|
|
19
67
|
// Add user message to context
|
|
20
68
|
this.context.addMessage({ role: 'user', content: userInput })
|
|
21
69
|
|
|
@@ -32,11 +80,13 @@ export class QueryEngine {
|
|
|
32
80
|
const toolUses: Array<{ id: string; name: string; input: Record<string, unknown> }> = []
|
|
33
81
|
|
|
34
82
|
// Stream model response
|
|
83
|
+
try {
|
|
35
84
|
for await (const chunk of this.registry.chat({
|
|
36
85
|
model: this.registry.getActiveModel(),
|
|
37
86
|
messages,
|
|
38
87
|
systemPrompt,
|
|
39
88
|
tools: toolDefs.length > 0 ? toolDefs : undefined,
|
|
89
|
+
signal,
|
|
40
90
|
})) {
|
|
41
91
|
yield chunk
|
|
42
92
|
|
|
@@ -64,6 +114,18 @@ export class QueryEngine {
|
|
|
64
114
|
}
|
|
65
115
|
}
|
|
66
116
|
}
|
|
117
|
+
} catch (err) {
|
|
118
|
+
if (isAbortError(err)) {
|
|
119
|
+
// User interrupted — keep partial content, stop gracefully
|
|
120
|
+
if (assistantContent) {
|
|
121
|
+
this.context.addMessage({ role: 'assistant', content: assistantContent })
|
|
122
|
+
}
|
|
123
|
+
yield { type: 'stop' }
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
yield { type: 'error', error: String(err) }
|
|
127
|
+
return
|
|
128
|
+
}
|
|
67
129
|
|
|
68
130
|
// Execute any tools that were requested
|
|
69
131
|
for (const toolUse of toolUses) {
|
|
@@ -71,7 +133,7 @@ export class QueryEngine {
|
|
|
71
133
|
yield {
|
|
72
134
|
type: 'tool_result',
|
|
73
135
|
tool_use_id: toolUse.id,
|
|
74
|
-
content: result.content,
|
|
136
|
+
content: result.success ? result.content : (result.error || result.content),
|
|
75
137
|
}
|
|
76
138
|
|
|
77
139
|
// Add tool use + result to context
|
|
@@ -87,60 +149,80 @@ export class QueryEngine {
|
|
|
87
149
|
|
|
88
150
|
// If tools were executed, recursively continue the conversation
|
|
89
151
|
if (toolUses.length > 0) {
|
|
90
|
-
yield* this.continueWithTools()
|
|
152
|
+
yield* this.continueWithTools(signal)
|
|
91
153
|
}
|
|
92
154
|
}
|
|
93
155
|
|
|
94
|
-
private async *continueWithTools(): AsyncGenerator<StreamChunk> {
|
|
95
|
-
const
|
|
96
|
-
const messages = this.context.getMessages()
|
|
156
|
+
private async *continueWithTools(signal?: AbortSignal): AsyncGenerator<StreamChunk> {
|
|
157
|
+
const MAX_TURNS = 10
|
|
97
158
|
const toolDefs = this.getToolDefinitions()
|
|
98
159
|
|
|
99
|
-
let
|
|
100
|
-
|
|
160
|
+
for (let turn = 0; turn < MAX_TURNS; turn++) {
|
|
161
|
+
const systemPrompt = this.context.getSystemPrompt()
|
|
162
|
+
const messages = this.context.getMessages()
|
|
101
163
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
messages,
|
|
105
|
-
systemPrompt,
|
|
106
|
-
tools: toolDefs.length > 0 ? toolDefs : undefined,
|
|
107
|
-
})) {
|
|
108
|
-
yield chunk
|
|
164
|
+
let assistantContent = ''
|
|
165
|
+
const toolUses: Array<{ id: string; name: string; input: Record<string, unknown> }> = []
|
|
109
166
|
|
|
110
|
-
|
|
111
|
-
|
|
167
|
+
try {
|
|
168
|
+
for await (const chunk of this.registry.chat({
|
|
169
|
+
model: this.registry.getActiveModel(),
|
|
170
|
+
messages,
|
|
171
|
+
systemPrompt,
|
|
172
|
+
tools: toolDefs.length > 0 ? toolDefs : undefined,
|
|
173
|
+
signal,
|
|
174
|
+
})) {
|
|
175
|
+
yield chunk
|
|
112
176
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
id: chunk.toolUse.id,
|
|
116
|
-
name: chunk.toolUse.name,
|
|
117
|
-
input: chunk.toolUse.input,
|
|
118
|
-
})
|
|
119
|
-
}
|
|
120
|
-
}
|
|
177
|
+
if (chunk.type === 'error') return
|
|
178
|
+
if (chunk.type === 'text' && chunk.content) assistantContent += chunk.content
|
|
121
179
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
180
|
+
if (chunk.type === 'tool_use' && chunk.toolUse) {
|
|
181
|
+
toolUses.push({
|
|
182
|
+
id: chunk.toolUse.id,
|
|
183
|
+
name: chunk.toolUse.name,
|
|
184
|
+
input: chunk.toolUse.input,
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
} catch (err) {
|
|
189
|
+
if (isAbortError(err)) {
|
|
190
|
+
if (assistantContent) {
|
|
191
|
+
this.context.addMessage({ role: 'assistant', content: assistantContent })
|
|
192
|
+
}
|
|
193
|
+
yield { type: 'stop' }
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
return
|
|
197
|
+
}
|
|
125
198
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const result = await this.executeTool(toolUse.name, toolUse.input)
|
|
129
|
-
yield {
|
|
130
|
-
type: 'tool_result',
|
|
131
|
-
tool_use_id: toolUse.id,
|
|
132
|
-
content: result.content,
|
|
199
|
+
if (assistantContent) {
|
|
200
|
+
this.context.addMessage({ role: 'assistant', content: assistantContent })
|
|
133
201
|
}
|
|
134
202
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
203
|
+
// No more tool calls — conversation complete
|
|
204
|
+
if (toolUses.length === 0) return
|
|
205
|
+
|
|
206
|
+
// Execute tools and feed results back to the model for the next turn
|
|
207
|
+
for (const toolUse of toolUses) {
|
|
208
|
+
const result = await this.executeTool(toolUse.name, toolUse.input)
|
|
209
|
+
yield {
|
|
210
|
+
type: 'tool_result',
|
|
211
|
+
tool_use_id: toolUse.id,
|
|
212
|
+
content: result.content,
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
this.context.addMessage({
|
|
216
|
+
role: 'assistant',
|
|
217
|
+
content: [{ type: 'tool_use', id: toolUse.id, name: toolUse.name, input: toolUse.input }],
|
|
218
|
+
})
|
|
219
|
+
this.context.addMessage({
|
|
220
|
+
role: 'user',
|
|
221
|
+
content: [{ type: 'tool_result', tool_use_id: toolUse.id, content: result.content }],
|
|
222
|
+
})
|
|
223
|
+
}
|
|
143
224
|
}
|
|
225
|
+
// Max turns reached — safety limit, stop gracefully
|
|
144
226
|
}
|
|
145
227
|
|
|
146
228
|
private async executeTool(name: string, params: Record<string, unknown>): Promise<ToolResult> {
|
|
@@ -158,13 +240,40 @@ export class QueryEngine {
|
|
|
158
240
|
}
|
|
159
241
|
}
|
|
160
242
|
|
|
243
|
+
// Run PreToolUse hooks
|
|
244
|
+
let effectiveParams = params
|
|
245
|
+
if (this.hookEngine) {
|
|
246
|
+
const preResult = await this.hookEngine.executePreToolUse(
|
|
247
|
+
name,
|
|
248
|
+
params,
|
|
249
|
+
'session-1',
|
|
250
|
+
)
|
|
251
|
+
if (!preResult.allowed) {
|
|
252
|
+
return {
|
|
253
|
+
success: false,
|
|
254
|
+
content: '',
|
|
255
|
+
error: preResult.reason || `Tool "${name}" blocked by hook`,
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (preResult.modifiedInput) {
|
|
259
|
+
effectiveParams = { ...params, ...preResult.modifiedInput }
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
161
263
|
try {
|
|
162
|
-
|
|
264
|
+
const result = await tool.execute(effectiveParams, {
|
|
163
265
|
cwd: process.cwd(),
|
|
164
266
|
sessionId: 'session-1',
|
|
165
267
|
provider: this.registry.getActive().config.id,
|
|
166
268
|
model: this.registry.getActiveModel(),
|
|
167
269
|
})
|
|
270
|
+
|
|
271
|
+
// Run PostToolUse hooks
|
|
272
|
+
if (this.hookEngine) {
|
|
273
|
+
await this.hookEngine.executePostToolUse(name, effectiveParams, result, 'session-1')
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return result
|
|
168
277
|
} catch (err) {
|
|
169
278
|
return { success: false, content: '', error: String(err) }
|
|
170
279
|
}
|
|
@@ -191,3 +300,9 @@ export class QueryEngine {
|
|
|
191
300
|
this.registry.switchProvider(providerId, modelId)
|
|
192
301
|
}
|
|
193
302
|
}
|
|
303
|
+
|
|
304
|
+
function isAbortError(err: unknown): boolean {
|
|
305
|
+
if (err instanceof Error && err.name === 'AbortError') return true
|
|
306
|
+
if (typeof DOMException !== 'undefined' && err instanceof DOMException && err.name === 'AbortError') return true
|
|
307
|
+
return false
|
|
308
|
+
}
|
package/src/core/permission.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type { ToolDefinition, ToolPermission, PermissionLevel } from '../shared/index.ts'
|
|
1
|
+
import type { ToolDefinition, ToolPermission, PermissionLevel, PermissionRule } from '../shared/index.ts'
|
|
2
2
|
|
|
3
3
|
export class PermissionSystem {
|
|
4
4
|
private rules = new Map<string, PermissionLevel>()
|
|
5
|
+
private patternRules: Array<{ pattern: RegExp; level: PermissionLevel }> = []
|
|
5
6
|
|
|
6
7
|
constructor(private defaultLevel: ToolPermission = 'auto') {}
|
|
7
8
|
|
|
@@ -13,18 +14,43 @@ export class PermissionSystem {
|
|
|
13
14
|
return this.defaultLevel
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
setRule(
|
|
17
|
-
|
|
17
|
+
setRule(toolNameOrRule: string | PermissionRule, level?: PermissionLevel): void {
|
|
18
|
+
if (typeof toolNameOrRule === 'string') {
|
|
19
|
+
if (level) this.rules.set(toolNameOrRule, level)
|
|
20
|
+
} else {
|
|
21
|
+
const rule = toolNameOrRule
|
|
22
|
+
if (rule.pattern) {
|
|
23
|
+
this.patternRules.push({ pattern: new RegExp(rule.pattern), level: rule.level })
|
|
24
|
+
} else {
|
|
25
|
+
this.rules.set(rule.toolName, rule.level)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
18
28
|
}
|
|
19
29
|
|
|
20
30
|
removeRule(toolName: string): void {
|
|
21
31
|
this.rules.delete(toolName)
|
|
22
32
|
}
|
|
23
33
|
|
|
24
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Resolve permission level for a tool call.
|
|
36
|
+
*
|
|
37
|
+
* Fallback chain: exact rule → pattern rule → tool permission → system default
|
|
38
|
+
*/
|
|
39
|
+
check(tool: ToolDefinition, input: Record<string, unknown>): PermissionLevel {
|
|
40
|
+
// 1. Exact-name rule wins
|
|
25
41
|
const ruleLevel = this.rules.get(tool.name)
|
|
26
42
|
if (ruleLevel) return ruleLevel
|
|
27
|
-
|
|
43
|
+
|
|
44
|
+
// 2. Pattern-based rules (e.g. "file_*" → bypass)
|
|
45
|
+
for (const { pattern, level } of this.patternRules) {
|
|
46
|
+
if (pattern.test(tool.name)) return level
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 3. Tool's own permission level
|
|
50
|
+
if (tool.permission) return tool.permission
|
|
51
|
+
|
|
52
|
+
// 4. System default
|
|
53
|
+
return this.defaultLevel
|
|
28
54
|
}
|
|
29
55
|
|
|
30
56
|
needsApproval(tool: ToolDefinition, input: Record<string, unknown>): boolean {
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
readFileSync,
|
|
5
5
|
writeFileSync,
|
|
6
6
|
unlinkSync,
|
|
7
|
+
renameSync,
|
|
7
8
|
existsSync,
|
|
8
9
|
} from 'node:fs'
|
|
9
10
|
import { join } from 'node:path'
|
|
@@ -60,7 +61,10 @@ export class SessionStore {
|
|
|
60
61
|
messages,
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
|
|
64
|
+
// Atomic write: write to temp file, then rename (same-fs rename is atomic)
|
|
65
|
+
const tmp = path + '.tmp'
|
|
66
|
+
writeFileSync(tmp, JSON.stringify(session) + '\n', 'utf-8')
|
|
67
|
+
renameSync(tmp, path)
|
|
64
68
|
}
|
|
65
69
|
|
|
66
70
|
/**
|