@miphamai/cli 0.4.0 → 0.5.1
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/bin/mipham +51 -24
- package/bin/mipham.ts +186 -0
- package/package.json +1 -1
- package/src/agent/agent-context.ts +56 -0
- package/src/agent/agent-registry.ts +134 -0
- package/src/agent/sub-agent.ts +73 -88
- package/src/agent/types.ts +39 -0
- package/src/agent-view/agent-view-manager.ts +217 -0
- package/src/agent-view/dashboard.tsx +218 -0
- package/src/agent-view/session-peek.tsx +72 -0
- package/src/agent-view/session-row.tsx +70 -0
- package/src/artifacts/server.ts +31 -0
- package/src/artifacts/versioning.ts +127 -0
- package/src/config/defaults.ts +2 -1
- package/src/core/context-compact.ts +50 -0
- package/src/core/context-drain.ts +54 -0
- package/src/core/context-microcompact.ts +132 -0
- package/src/core/context-snip.ts +74 -0
- package/src/core/context-token.ts +108 -0
- package/src/core/context.ts +169 -0
- package/src/core/engine.ts +144 -3
- package/src/core/hooks-config.ts +52 -0
- package/src/core/hooks-executor.ts +113 -0
- package/src/core/hooks.ts +90 -30
- package/src/core/instructions.ts +11 -0
- package/src/core/memory/memory-loader.ts +23 -0
- package/src/core/memory/memory-manager.ts +192 -0
- package/src/core/memory/memory-writer.ts +72 -0
- package/src/core/permission-config.ts +34 -0
- package/src/core/permission-rules.ts +66 -0
- package/src/core/permission.ts +221 -36
- package/src/index.tsx +20 -0
- package/src/plugin/plugin-loader.ts +36 -0
- package/src/plugin/plugin-manager.ts +125 -0
- package/src/plugin/plugin-validator.ts +41 -0
- package/src/shared/types.ts +61 -1
- package/src/skills/fork-executor.ts +40 -0
- package/src/skills/loader.ts +46 -0
- package/src/tools/agent/agent.ts +20 -11
- package/src/tools/agent/skill.ts +32 -2
- package/src/tools/computer/app-launcher.ts +39 -0
- package/src/tools/computer/browser.ts +48 -0
- package/src/tools/computer/computer-use.ts +88 -0
- package/src/tools/computer/playwright.d.ts +7 -0
- package/src/tools/computer/screenshot.ts +57 -0
- package/src/tools/index.ts +3 -0
- package/src/tools/network/web-fetch.ts +1 -1
- package/src/ui/app.tsx +14 -3
- package/src/ui/commands.ts +101 -32
- package/src/ui/input.tsx +123 -8
- package/src/ui/vim-motions.ts +96 -0
- package/src/workflow/budget.ts +33 -0
- package/src/workflow/journal.ts +105 -0
- package/src/workflow/primitives/agent.ts +51 -0
- package/src/workflow/primitives/parallel.ts +8 -0
- package/src/workflow/primitives/phase.ts +19 -0
- package/src/workflow/primitives/pipeline.ts +24 -0
- package/src/workflow/runtime.ts +87 -0
- package/src/workflow/sandbox.ts +79 -0
- package/dist/mipham +0 -0
package/src/core/hooks.ts
CHANGED
|
@@ -4,8 +4,23 @@ import type {
|
|
|
4
4
|
HookContext,
|
|
5
5
|
HookResult,
|
|
6
6
|
ToolResult,
|
|
7
|
+
HookConfig,
|
|
7
8
|
} from '../shared/index.ts'
|
|
8
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Merge two permission decisions, keeping the more restrictive one.
|
|
12
|
+
* Priority: deny > defer > ask > allow
|
|
13
|
+
*/
|
|
14
|
+
export function mergePermissionDecision(
|
|
15
|
+
current: HookResult['permissionDecision'],
|
|
16
|
+
incoming: NonNullable<HookResult['permissionDecision']>,
|
|
17
|
+
): HookResult['permissionDecision'] {
|
|
18
|
+
const priority: Record<string, number> = { deny: 3, defer: 2, ask: 1, allow: 0 }
|
|
19
|
+
const currScore = current ? (priority[current] ?? 0) : -1
|
|
20
|
+
const incScore = priority[incoming] ?? 0
|
|
21
|
+
return incScore > currScore ? incoming : current
|
|
22
|
+
}
|
|
23
|
+
|
|
9
24
|
export class HookEngine {
|
|
10
25
|
private hooks: HookDefinition[] = []
|
|
11
26
|
|
|
@@ -19,18 +34,14 @@ export class HookEngine {
|
|
|
19
34
|
)
|
|
20
35
|
}
|
|
21
36
|
|
|
37
|
+
// ── Existing event executors ──
|
|
38
|
+
|
|
22
39
|
async executePreToolUse(
|
|
23
40
|
toolName: string,
|
|
24
41
|
toolInput: Record<string, unknown>,
|
|
25
42
|
sessionId: string,
|
|
26
43
|
): Promise<HookResult> {
|
|
27
|
-
const ctx: HookContext = {
|
|
28
|
-
event: 'PreToolUse',
|
|
29
|
-
toolName,
|
|
30
|
-
toolInput,
|
|
31
|
-
sessionId,
|
|
32
|
-
}
|
|
33
|
-
|
|
44
|
+
const ctx: HookContext = { event: 'PreToolUse', toolName, toolInput, sessionId }
|
|
34
45
|
return this.runHooks('PreToolUse', toolName, ctx)
|
|
35
46
|
}
|
|
36
47
|
|
|
@@ -40,45 +51,64 @@ export class HookEngine {
|
|
|
40
51
|
toolResult: ToolResult,
|
|
41
52
|
sessionId: string,
|
|
42
53
|
): Promise<HookResult> {
|
|
43
|
-
const ctx: HookContext = {
|
|
44
|
-
event: 'PostToolUse',
|
|
45
|
-
toolName,
|
|
46
|
-
toolInput,
|
|
47
|
-
toolResult,
|
|
48
|
-
sessionId,
|
|
49
|
-
}
|
|
50
|
-
|
|
54
|
+
const ctx: HookContext = { event: 'PostToolUse', toolName, toolInput, toolResult, sessionId }
|
|
51
55
|
return this.runHooks('PostToolUse', toolName, ctx)
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
async executeSessionStart(sessionId: string): Promise<HookResult> {
|
|
55
|
-
const ctx: HookContext = {
|
|
56
|
-
event: 'SessionStart',
|
|
57
|
-
sessionId,
|
|
58
|
-
}
|
|
59
|
-
|
|
59
|
+
const ctx: HookContext = { event: 'SessionStart', sessionId }
|
|
60
60
|
return this.runHooks('SessionStart', undefined, ctx)
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
async executeSessionEnd(sessionId: string): Promise<HookResult> {
|
|
64
|
-
const ctx: HookContext = {
|
|
65
|
-
event: 'SessionEnd',
|
|
66
|
-
sessionId,
|
|
67
|
-
}
|
|
68
|
-
|
|
64
|
+
const ctx: HookContext = { event: 'SessionEnd', sessionId }
|
|
69
65
|
return this.runHooks('SessionEnd', undefined, ctx)
|
|
70
66
|
}
|
|
71
67
|
|
|
72
68
|
async executeNotification(message: string, sessionId: string): Promise<HookResult> {
|
|
69
|
+
const ctx: HookContext = { event: 'Notification', sessionId, toolInput: { message } }
|
|
70
|
+
return this.runHooks('Notification', undefined, ctx)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── NEW event executors ──
|
|
74
|
+
|
|
75
|
+
/** Stop hook: fires when AI completes a turn. Can block to force continuation. */
|
|
76
|
+
async executeStop(sessionId: string): Promise<HookResult> {
|
|
77
|
+
const ctx: HookContext = { event: 'Stop', sessionId }
|
|
78
|
+
return this.runHooks('Stop', undefined, ctx)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** UserPromptSubmit: fires when user submits input. Can block malicious input. */
|
|
82
|
+
async executeUserPromptSubmit(prompt: string, sessionId: string): Promise<HookResult> {
|
|
83
|
+
const ctx: HookContext = { event: 'UserPromptSubmit', sessionId, userPrompt: prompt }
|
|
84
|
+
return this.runHooks('UserPromptSubmit', undefined, ctx)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** PreCompact: fires before context compaction. */
|
|
88
|
+
async executePreCompact(sessionId: string): Promise<HookResult> {
|
|
89
|
+
const ctx: HookContext = { event: 'PreCompact', sessionId }
|
|
90
|
+
return this.runHooks('PreCompact', undefined, ctx)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** PostCompact: fires after context compaction. Can inject additional context. */
|
|
94
|
+
async executePostCompact(sessionId: string): Promise<HookResult> {
|
|
95
|
+
const ctx: HookContext = { event: 'PostCompact', sessionId }
|
|
96
|
+
return this.runHooks('PostCompact', undefined, ctx)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** ConfigChange: fires when configuration changes. */
|
|
100
|
+
async executeConfigChange(key: string, value: unknown, sessionId: string): Promise<HookResult> {
|
|
73
101
|
const ctx: HookContext = {
|
|
74
|
-
event: '
|
|
102
|
+
event: 'ConfigChange',
|
|
75
103
|
sessionId,
|
|
76
|
-
|
|
104
|
+
configKey: key,
|
|
105
|
+
configValue: value,
|
|
77
106
|
}
|
|
78
|
-
|
|
79
|
-
return this.runHooks('Notification', undefined, ctx)
|
|
107
|
+
return this.runHooks('ConfigChange', undefined, ctx)
|
|
80
108
|
}
|
|
81
109
|
|
|
110
|
+
// ── Core execution ──
|
|
111
|
+
|
|
82
112
|
private async runHooks(
|
|
83
113
|
event: HookEvent,
|
|
84
114
|
toolName: string | undefined,
|
|
@@ -93,15 +123,45 @@ export class HookEngine {
|
|
|
93
123
|
for (const hook of matching) {
|
|
94
124
|
try {
|
|
95
125
|
const hookResult = await hook.handler(ctx)
|
|
126
|
+
|
|
127
|
+
// Block on first deny — stops further hook execution
|
|
96
128
|
if (!hookResult.allowed) {
|
|
97
|
-
return hookResult
|
|
129
|
+
return { ...hookResult }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Merge permission decisions (deny > defer > ask > allow)
|
|
133
|
+
if (hookResult.permissionDecision) {
|
|
134
|
+
result.permissionDecision = mergePermissionDecision(
|
|
135
|
+
result.permissionDecision,
|
|
136
|
+
hookResult.permissionDecision,
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Stop hook: block decision takes precedence
|
|
141
|
+
if (hookResult.decision === 'block') {
|
|
142
|
+
result.decision = 'block'
|
|
143
|
+
result.reason = hookResult.reason || result.reason
|
|
98
144
|
}
|
|
145
|
+
|
|
146
|
+
// Merge modified inputs
|
|
99
147
|
if (hookResult.modifiedInput) {
|
|
100
148
|
result.modifiedInput = {
|
|
101
149
|
...(result.modifiedInput || {}),
|
|
102
150
|
...hookResult.modifiedInput,
|
|
103
151
|
}
|
|
104
152
|
}
|
|
153
|
+
|
|
154
|
+
// Collect additional context from multiple hooks
|
|
155
|
+
if (hookResult.additionalContext) {
|
|
156
|
+
result.additionalContext = result.additionalContext
|
|
157
|
+
? result.additionalContext + '\n' + hookResult.additionalContext
|
|
158
|
+
: hookResult.additionalContext
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// PostToolUse: replace output if provided
|
|
162
|
+
if (hookResult.updatedOutput) {
|
|
163
|
+
result.updatedOutput = hookResult.updatedOutput
|
|
164
|
+
}
|
|
105
165
|
} catch {
|
|
106
166
|
// Hook failures do not block execution
|
|
107
167
|
}
|
package/src/core/instructions.ts
CHANGED
|
@@ -21,6 +21,7 @@ function parseFrontmatter(raw: string): FrontmatterResult {
|
|
|
21
21
|
|
|
22
22
|
export class InstructionsLoader {
|
|
23
23
|
private instructions: InstructionFile[] = []
|
|
24
|
+
private skillsReminder = ''
|
|
24
25
|
|
|
25
26
|
loadAll(cwd: string): void {
|
|
26
27
|
this.instructions = []
|
|
@@ -60,9 +61,19 @@ export class InstructionsLoader {
|
|
|
60
61
|
parts.push(`<!-- ${levelLabel[inst.level] || inst.level} (${inst.path}) -->\n${inst.content}`)
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
// Append skills reminder after all instructions
|
|
65
|
+
if (this.skillsReminder) {
|
|
66
|
+
parts.push(this.skillsReminder)
|
|
67
|
+
}
|
|
68
|
+
|
|
63
69
|
return parts.join('\n\n---\n\n')
|
|
64
70
|
}
|
|
65
71
|
|
|
72
|
+
/** Set the skills system-reminder block to inject into the system prompt. */
|
|
73
|
+
setSkillsReminder(reminder: string): void {
|
|
74
|
+
this.skillsReminder = reminder
|
|
75
|
+
}
|
|
76
|
+
|
|
66
77
|
list(): InstructionFile[] {
|
|
67
78
|
return [...this.instructions]
|
|
68
79
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { MemoryManager } from './memory-manager'
|
|
2
|
+
import type { MemoryManager as MemoryManagerType } from './memory-manager'
|
|
3
|
+
|
|
4
|
+
const MEMORY_DIR = `${process.env.HOME || '~'}/.mipham/memory`
|
|
5
|
+
|
|
6
|
+
let instance: MemoryManagerType | null = null
|
|
7
|
+
|
|
8
|
+
export function getMemoryManager(): MemoryManagerType {
|
|
9
|
+
if (!instance) {
|
|
10
|
+
instance = new MemoryManager(MEMORY_DIR)
|
|
11
|
+
instance.loadAll()
|
|
12
|
+
}
|
|
13
|
+
return instance
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Load relevant memories for the current session and inject as
|
|
18
|
+
* a system-reminder block. Called at SessionStart.
|
|
19
|
+
*/
|
|
20
|
+
export function loadSessionMemories(context: string): string {
|
|
21
|
+
const mm = getMemoryManager()
|
|
22
|
+
return mm.buildSystemReminder(context)
|
|
23
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync,
|
|
3
|
+
readdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
writeFileSync,
|
|
6
|
+
unlinkSync,
|
|
7
|
+
existsSync,
|
|
8
|
+
} from 'node:fs'
|
|
9
|
+
import { join, extname } from 'node:path'
|
|
10
|
+
|
|
11
|
+
export interface MemoryMetadata {
|
|
12
|
+
type: 'user' | 'feedback' | 'project' | 'reference'
|
|
13
|
+
relevance: string[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface MemoryEntry {
|
|
17
|
+
name: string
|
|
18
|
+
description: string
|
|
19
|
+
metadata: MemoryMetadata
|
|
20
|
+
content: string
|
|
21
|
+
filePath: string
|
|
22
|
+
updatedAt: Date
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const INDEX_FILE = 'MEMORY.md'
|
|
26
|
+
|
|
27
|
+
export class MemoryManager {
|
|
28
|
+
private memories = new Map<string, MemoryEntry>()
|
|
29
|
+
|
|
30
|
+
constructor(private memoryDir: string) {
|
|
31
|
+
mkdirSync(memoryDir, { recursive: true })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
loadAll(): void {
|
|
35
|
+
this.memories.clear()
|
|
36
|
+
if (!existsSync(this.memoryDir)) return
|
|
37
|
+
|
|
38
|
+
let entries: string[] = []
|
|
39
|
+
try {
|
|
40
|
+
entries = readdirSync(this.memoryDir)
|
|
41
|
+
} catch {
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
if (entry === INDEX_FILE || extname(entry) !== '.md') continue
|
|
47
|
+
const filePath = join(this.memoryDir, entry)
|
|
48
|
+
try {
|
|
49
|
+
const raw = readFileSync(filePath, 'utf-8')
|
|
50
|
+
const parsed = this.parseMemoryFile(raw, filePath)
|
|
51
|
+
if (parsed) {
|
|
52
|
+
this.memories.set(parsed.name, parsed)
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// skip unparseable
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
write(name: string, content: string, metadata: MemoryMetadata): void {
|
|
61
|
+
const fileName = `${name}.md`
|
|
62
|
+
const filePath = join(this.memoryDir, fileName)
|
|
63
|
+
|
|
64
|
+
const body = this.formatMemoryFile(name, metadata, content)
|
|
65
|
+
writeFileSync(filePath, body, 'utf-8')
|
|
66
|
+
|
|
67
|
+
const entry: MemoryEntry = {
|
|
68
|
+
name,
|
|
69
|
+
description: metadata.relevance.join(', '),
|
|
70
|
+
metadata,
|
|
71
|
+
content,
|
|
72
|
+
filePath,
|
|
73
|
+
updatedAt: new Date(),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
this.memories.set(name, entry)
|
|
77
|
+
this.updateIndex()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
recall(context: string, limit: number = 10): MemoryEntry[] {
|
|
81
|
+
const contextLower = context.toLowerCase()
|
|
82
|
+
const scored: Array<{ entry: MemoryEntry; score: number }> = []
|
|
83
|
+
|
|
84
|
+
for (const entry of this.memories.values()) {
|
|
85
|
+
let score = 0
|
|
86
|
+
// Match against relevance tags
|
|
87
|
+
for (const tag of entry.metadata.relevance) {
|
|
88
|
+
if (contextLower.includes(tag.toLowerCase())) score += 3
|
|
89
|
+
}
|
|
90
|
+
// Match against content keywords
|
|
91
|
+
const contentWords = entry.content.toLowerCase().split(/\s+/)
|
|
92
|
+
const contextWords = new Set(contextLower.split(/\s+/))
|
|
93
|
+
for (const word of contentWords) {
|
|
94
|
+
if (contextWords.has(word) && word.length > 3) score += 1
|
|
95
|
+
}
|
|
96
|
+
if (score > 0) {
|
|
97
|
+
scored.push({ entry, score })
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
scored.sort((a, b) => b.score - a.score)
|
|
102
|
+
return scored.slice(0, limit).map((s) => s.entry)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
delete(name: string): void {
|
|
106
|
+
const entry = this.memories.get(name)
|
|
107
|
+
if (!entry) return
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
unlinkSync(entry.filePath)
|
|
111
|
+
} catch {
|
|
112
|
+
// file may already be gone
|
|
113
|
+
}
|
|
114
|
+
this.memories.delete(name)
|
|
115
|
+
this.updateIndex()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
buildSystemReminder(context: string, maxTokens: number = 5000): string {
|
|
119
|
+
const relevant = this.recall(context, 10)
|
|
120
|
+
if (relevant.length === 0) return ''
|
|
121
|
+
|
|
122
|
+
const lines: string[] = ['<system-reminder>', 'Relevant memories from previous sessions:']
|
|
123
|
+
|
|
124
|
+
let tokenBudget = 50 // opening tags
|
|
125
|
+
for (const entry of relevant) {
|
|
126
|
+
const line = `- ${entry.name}: ${entry.content.slice(0, 200)}`
|
|
127
|
+
const lineTokens = Math.ceil(line.length / 4)
|
|
128
|
+
if (tokenBudget + lineTokens > maxTokens) break
|
|
129
|
+
lines.push(line)
|
|
130
|
+
tokenBudget += lineTokens
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
lines.push('</system-reminder>')
|
|
134
|
+
return lines.join('\n')
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private updateIndex(): void {
|
|
138
|
+
const lines: string[] = [
|
|
139
|
+
'# Memory Index',
|
|
140
|
+
'',
|
|
141
|
+
`_Last updated: ${new Date().toISOString()}_`,
|
|
142
|
+
'',
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
for (const entry of this.memories.values()) {
|
|
146
|
+
lines.push(`- [${entry.name}](${entry.name}.md) — ${entry.description}`)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
writeFileSync(join(this.memoryDir, INDEX_FILE), lines.join('\n') + '\n', 'utf-8')
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private parseMemoryFile(raw: string, filePath: string): MemoryEntry | null {
|
|
153
|
+
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
|
|
154
|
+
if (!match) return null
|
|
155
|
+
|
|
156
|
+
const frontmatter = match[1] || ''
|
|
157
|
+
const content = (match[2] || '').trim()
|
|
158
|
+
|
|
159
|
+
// Parse simple YAML-like frontmatter
|
|
160
|
+
const nameMatch = frontmatter.match(/name:\s*(\S+)/)
|
|
161
|
+
const descMatch = frontmatter.match(/description:\s*(.+)/)
|
|
162
|
+
const typeMatch = frontmatter.match(/type:\s*(\S+)/)
|
|
163
|
+
const relevanceMatch = frontmatter.match(/relevance:\s*\[(.+)\]/)
|
|
164
|
+
|
|
165
|
+
if (!nameMatch) return null
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
name: nameMatch[1]!,
|
|
169
|
+
description: descMatch?.[1]?.trim() || '',
|
|
170
|
+
metadata: {
|
|
171
|
+
type: (typeMatch?.[1] as MemoryMetadata['type']) || 'reference',
|
|
172
|
+
relevance: relevanceMatch?.[1]?.split(',').map((s) => s.trim()) || [],
|
|
173
|
+
},
|
|
174
|
+
content,
|
|
175
|
+
filePath,
|
|
176
|
+
updatedAt: new Date(),
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private formatMemoryFile(name: string, metadata: MemoryMetadata, content: string): string {
|
|
181
|
+
return `---
|
|
182
|
+
name: ${name}
|
|
183
|
+
description: ${metadata.relevance.join(', ')}
|
|
184
|
+
metadata:
|
|
185
|
+
type: ${metadata.type}
|
|
186
|
+
relevance: [${metadata.relevance.join(', ')}]
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
${content}
|
|
190
|
+
`
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { MemoryManager } from './memory-manager'
|
|
2
|
+
|
|
3
|
+
const MEMORY_TRIGGERS = [
|
|
4
|
+
{
|
|
5
|
+
pattern: /(?:以后|今后|从现在开始|always|from now on|记住|remember)\s*[::]\s*(.+)/i,
|
|
6
|
+
type: 'user' as const,
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
pattern: /(?:偏好|prefer|preference|习惯)\s*[::]\s*(.+)/i,
|
|
10
|
+
type: 'user' as const,
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
pattern: /(?:项目|project)\s+(?:使用|uses?|采用)\s+(.+)/i,
|
|
14
|
+
type: 'project' as const,
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
pattern: /(?:决策|decided?|决定)\s*[::]\s*(.+)/i,
|
|
18
|
+
type: 'project' as const,
|
|
19
|
+
},
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Analyze the user's message for memory-worthy content and persist it.
|
|
24
|
+
*/
|
|
25
|
+
export function analyzeForMemory(userMessage: string, memoryManager: MemoryManager): void {
|
|
26
|
+
for (const trigger of MEMORY_TRIGGERS) {
|
|
27
|
+
const match = userMessage.match(trigger.pattern)
|
|
28
|
+
if (match?.[1]) {
|
|
29
|
+
const content = match[1].trim()
|
|
30
|
+
const name = `auto-${trigger.type}-${Date.now()}`
|
|
31
|
+
const relevance = extractKeywords(content)
|
|
32
|
+
|
|
33
|
+
memoryManager.write(name, content, {
|
|
34
|
+
type: trigger.type === 'user' ? 'user' : 'project',
|
|
35
|
+
relevance,
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function extractKeywords(text: string): string[] {
|
|
42
|
+
// Simple keyword extraction: words > 3 chars, no stop words
|
|
43
|
+
const stopWords = new Set([
|
|
44
|
+
'this',
|
|
45
|
+
'that',
|
|
46
|
+
'with',
|
|
47
|
+
'from',
|
|
48
|
+
'have',
|
|
49
|
+
'been',
|
|
50
|
+
'were',
|
|
51
|
+
'they',
|
|
52
|
+
'will',
|
|
53
|
+
'would',
|
|
54
|
+
'could',
|
|
55
|
+
'should',
|
|
56
|
+
'about',
|
|
57
|
+
'which',
|
|
58
|
+
'their',
|
|
59
|
+
'使用',
|
|
60
|
+
'以后',
|
|
61
|
+
'现在',
|
|
62
|
+
'可以',
|
|
63
|
+
'需要',
|
|
64
|
+
'不要',
|
|
65
|
+
'应该',
|
|
66
|
+
'已经',
|
|
67
|
+
])
|
|
68
|
+
return text
|
|
69
|
+
.split(/\s+/)
|
|
70
|
+
.filter((w) => w.length > 3 && !stopWords.has(w.toLowerCase()))
|
|
71
|
+
.slice(0, 5)
|
|
72
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { PermissionConfig, PermissionMode } from '../shared/index.ts'
|
|
2
|
+
|
|
3
|
+
const DEFAULT_CONFIG: PermissionConfig = {
|
|
4
|
+
mode: 'default',
|
|
5
|
+
allow: [],
|
|
6
|
+
deny: [],
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Load permission configuration from a settings object.
|
|
11
|
+
* Merges with defaults for missing fields.
|
|
12
|
+
*/
|
|
13
|
+
export function loadPermissionConfig(raw: Partial<PermissionConfig> = {}): PermissionConfig {
|
|
14
|
+
return {
|
|
15
|
+
mode: (raw.mode as PermissionMode) || DEFAULT_CONFIG.mode,
|
|
16
|
+
allow: Array.isArray(raw.allow) ? raw.allow : [...DEFAULT_CONFIG.allow],
|
|
17
|
+
deny: Array.isArray(raw.deny) ? raw.deny : [...DEFAULT_CONFIG.deny],
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Valid mode transition order for Shift+Tab cycling. */
|
|
22
|
+
export const MODE_CYCLE: PermissionMode[] = [
|
|
23
|
+
'default',
|
|
24
|
+
'acceptEdits',
|
|
25
|
+
'plan',
|
|
26
|
+
'auto',
|
|
27
|
+
'dontAsk',
|
|
28
|
+
'bypassPermissions',
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
export function nextMode(current: PermissionMode): PermissionMode {
|
|
32
|
+
const idx = MODE_CYCLE.indexOf(current)
|
|
33
|
+
return MODE_CYCLE[(idx + 1) % MODE_CYCLE.length]!
|
|
34
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { PermissionRuleEntry } from '../shared/index.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Match a Bash(command_pattern) rule against an actual tool call.
|
|
5
|
+
*
|
|
6
|
+
* Pattern formats:
|
|
7
|
+
* "Bash" → matches any Bash call
|
|
8
|
+
* "Bash(git:*)" → matches "git status", "git diff --cached", etc.
|
|
9
|
+
* "Bash(npm test:*)" → matches "npm test -- --coverage"
|
|
10
|
+
* "Write(/etc/*)" → matches Write to /etc/passwd, /etc/hosts, etc.
|
|
11
|
+
*/
|
|
12
|
+
export function matchBashRule(
|
|
13
|
+
pattern: string,
|
|
14
|
+
toolName: string,
|
|
15
|
+
toolInput: Record<string, unknown>,
|
|
16
|
+
): boolean {
|
|
17
|
+
// Check if pattern has a parenthesized sub-pattern
|
|
18
|
+
const parenMatch = pattern.match(/^(\w+)\((.+)\)$/)
|
|
19
|
+
if (!parenMatch) {
|
|
20
|
+
// Plain tool name match: "Bash", "Write"
|
|
21
|
+
return toolName === pattern
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const [, baseTool, subPattern] = parenMatch
|
|
25
|
+
|
|
26
|
+
if (toolName !== baseTool!) return false
|
|
27
|
+
|
|
28
|
+
// For Bash: match against the command string
|
|
29
|
+
if (baseTool === 'Bash') {
|
|
30
|
+
const cmd = String(toolInput.command || '')
|
|
31
|
+
return wildcardMatch(subPattern!, cmd.trim())
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// For Write/Edit: match against the file_path
|
|
35
|
+
if (baseTool === 'Write' || baseTool === 'Edit') {
|
|
36
|
+
const path = String(toolInput.file_path || '')
|
|
37
|
+
return wildcardMatch(subPattern!, path)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return false
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function wildcardMatch(pattern: string, input: string): boolean {
|
|
44
|
+
const regexStr =
|
|
45
|
+
'^' +
|
|
46
|
+
pattern
|
|
47
|
+
.replace(/[.+^${}()|[\]\\*?]/g, '\\$&') // escape regex specials (incl. * and ?)
|
|
48
|
+
.replace(/:/g, '[:\\s]') // : → match colon or whitespace
|
|
49
|
+
.replace(/\\\*/g, '.*') // * → .*
|
|
50
|
+
.replace(/\\\?/g, '.') + // ? → .
|
|
51
|
+
'$'
|
|
52
|
+
return new RegExp(regexStr).test(input)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Compile a rule pattern string into a PermissionRuleEntry. */
|
|
56
|
+
export function compileRule(pattern: string, level: 'allow' | 'deny' | 'ask'): PermissionRuleEntry {
|
|
57
|
+
const regexStr =
|
|
58
|
+
'^' +
|
|
59
|
+
pattern
|
|
60
|
+
.replace(/[.+^${}()|[\]\\*?]/g, '\\$&')
|
|
61
|
+
.replace(/:/g, '[:\\s]')
|
|
62
|
+
.replace(/\\\*/g, '.*')
|
|
63
|
+
.replace(/\\\?/g, '.') +
|
|
64
|
+
'$'
|
|
65
|
+
return { pattern, level, compiled: new RegExp(regexStr) }
|
|
66
|
+
}
|