@miphamai/cli 0.22.0 → 0.24.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 +1 -1
- package/package.json +1 -1
- package/skills/workflows/audit.js +66 -18
- package/src/agent/agent-context.ts +20 -57
- package/src/agent/agent-experience.ts +8 -0
- package/src/agent/cross-session/discovery.ts +99 -0
- package/src/agent/cross-session/file-inbox.ts +118 -0
- package/src/agent/cross-session/transport.ts +17 -0
- package/src/agent/effectiveness-tracker.ts +149 -0
- package/src/agent/experience-rules.ts +188 -0
- package/src/agent/message-router.ts +83 -0
- package/src/agent/pattern-analyzer.ts +154 -0
- package/src/agent/sub-agent.ts +67 -0
- package/src/agent/types.ts +2 -0
- package/src/config/defaults.ts +12 -0
- package/src/config/loader.ts +33 -0
- package/src/core/credential-masker/env-filter.ts +39 -0
- package/src/core/credential-masker/index.ts +35 -0
- package/src/core/credential-masker/matcher.ts +106 -0
- package/src/core/credential-masker/output-scrub.ts +27 -0
- package/src/core/credential-masker/pipeline.ts +67 -0
- package/src/core/credential-masker/strategies/aws.ts +50 -0
- package/src/core/credential-masker/strategies/extract.ts +106 -0
- package/src/core/credential-masker/strategies/full.ts +15 -0
- package/src/core/credential-masker/strategies/jwt.ts +75 -0
- package/src/core/credential-masker/types.ts +16 -0
- package/src/core/credential-masker.ts +12 -187
- package/src/core/engine.ts +114 -10
- package/src/core/rule-engine.ts +179 -0
- package/src/core/session-store.ts +15 -2
- package/src/index.tsx +47 -2
- package/src/shared/types.ts +69 -5
- package/src/tools/agent/agent.ts +7 -1
- package/src/tools/agent/list-agents.ts +67 -0
- package/src/tools/agent/send-message.ts +21 -24
- package/src/tools/exec/bash.ts +67 -3
- package/src/tools/index.ts +2 -0
- package/src/ui/commands.ts +133 -0
package/src/core/engine.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
ToolDefinition,
|
|
4
4
|
ToolResult,
|
|
5
5
|
InferenceHookConfig,
|
|
6
|
+
CrsiConfig,
|
|
6
7
|
} from '../shared/index.ts'
|
|
7
8
|
import { ProviderRegistry } from '../providers/registry'
|
|
8
9
|
import { ContextManager } from './context'
|
|
@@ -16,8 +17,13 @@ import type { AgentViewManager } from '../agent-view/agent-view-manager'
|
|
|
16
17
|
import type { SkillsLoader } from '../skills/loader'
|
|
17
18
|
import { getBackgroundAgentRegistry } from '../agent/background-registry'
|
|
18
19
|
import { RulesLoader } from './rules-loader'
|
|
20
|
+
import { ExperienceRuleEngine } from './rule-engine.js'
|
|
21
|
+
import { PatternAnalyzer } from '../agent/pattern-analyzer.js'
|
|
22
|
+
import { EffectivenessTracker } from '../agent/effectiveness-tracker.js'
|
|
19
23
|
import { UsageTracker } from './usage-tracker'
|
|
20
24
|
import { buildRequest, sendInferenceCheck, isInferenceHookEnabled } from './inference-hook'
|
|
25
|
+
import { getFileInboxTransport } from '../agent/cross-session/file-inbox'
|
|
26
|
+
import { getMessageBus } from '../agent/message-bus'
|
|
21
27
|
import { createT } from '../i18n-core/t'
|
|
22
28
|
import enUS from '../i18n-core/locales/en-US.json'
|
|
23
29
|
import zhCN from '../i18n-core/locales/zh-CN.json'
|
|
@@ -35,6 +41,9 @@ export class QueryEngine {
|
|
|
35
41
|
private agentRegistry?: AgentRegistry
|
|
36
42
|
private agentViewManager?: AgentViewManager
|
|
37
43
|
private skillsLoader?: SkillsLoader
|
|
44
|
+
private ruleEngine?: ExperienceRuleEngine
|
|
45
|
+
private _patternAnalyzer?: PatternAnalyzer
|
|
46
|
+
private _effectivenessTracker?: EffectivenessTracker
|
|
38
47
|
private goal?: string
|
|
39
48
|
private maxGoalLoops = 20
|
|
40
49
|
private lastAssistantContent?: string
|
|
@@ -52,7 +61,40 @@ export class QueryEngine {
|
|
|
52
61
|
private context: ContextManager,
|
|
53
62
|
private tools: Map<string, ToolDefinition>,
|
|
54
63
|
private permission: PermissionSystem = new PermissionSystem('default'),
|
|
55
|
-
|
|
64
|
+
ruleEngine?: ExperienceRuleEngine,
|
|
65
|
+
) {
|
|
66
|
+
this.ruleEngine = ruleEngine
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Session identifier for cross-session messaging. Set by the app startup via setSessionId(). */
|
|
70
|
+
private sessionId: string = 'session-1'
|
|
71
|
+
|
|
72
|
+
/** Set the session identifier (called from index.tsx at startup with the actual session name). */
|
|
73
|
+
setSessionId(id: string): void {
|
|
74
|
+
this.sessionId = id
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Get the current session identifier. */
|
|
78
|
+
getSessionId(): string {
|
|
79
|
+
return this.sessionId
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Poll the cross-session file inbox for messages addressed to this session. */
|
|
83
|
+
async pollCrossSessionInbox(): Promise<void> {
|
|
84
|
+
try {
|
|
85
|
+
const transport = getFileInboxTransport()
|
|
86
|
+
const messages = await transport.poll(this.sessionId)
|
|
87
|
+
if (messages.length > 0) {
|
|
88
|
+
const bus = getMessageBus()
|
|
89
|
+
for (const msg of messages) {
|
|
90
|
+
bus.post(msg.from, msg.to, msg.summary, msg.message, msg.type)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
} catch (err) {
|
|
94
|
+
// Best-effort: don't let a failed inbox poll break the turn
|
|
95
|
+
console.warn('Cross-session inbox poll failed:', err)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
56
98
|
|
|
57
99
|
/** Register a hook engine for pre/post tool-use lifecycle events. */
|
|
58
100
|
setHookEngine(hooks: HookEngine): void {
|
|
@@ -96,6 +138,8 @@ export class QueryEngine {
|
|
|
96
138
|
private usageTracker = new UsageTracker()
|
|
97
139
|
/** Inference hook (DLP) configuration. */
|
|
98
140
|
private inferenceHookConfig?: InferenceHookConfig
|
|
141
|
+
/** CRSI feature flags. */
|
|
142
|
+
private crsiConfig?: Partial<CrsiConfig>
|
|
99
143
|
|
|
100
144
|
/** Register the rules loader. */
|
|
101
145
|
setRulesLoader(loader: RulesLoader): void {
|
|
@@ -108,6 +152,11 @@ export class QueryEngine {
|
|
|
108
152
|
this.inferenceHookConfig = config
|
|
109
153
|
}
|
|
110
154
|
|
|
155
|
+
/** Register CRSI feature flags. */
|
|
156
|
+
setCrsiConfig(config: Partial<CrsiConfig> | undefined): void {
|
|
157
|
+
this.crsiConfig = config
|
|
158
|
+
}
|
|
159
|
+
|
|
111
160
|
/** Pending task notifications from background agents (cleared after draining). */
|
|
112
161
|
private pendingTaskNotifications: Array<StreamChunk> = []
|
|
113
162
|
|
|
@@ -255,9 +304,12 @@ export class QueryEngine {
|
|
|
255
304
|
}
|
|
256
305
|
|
|
257
306
|
async *process(userInput: string, signal?: AbortSignal): AsyncGenerator<StreamChunk> {
|
|
307
|
+
// Poll for cross-session messages before each turn
|
|
308
|
+
await this.pollCrossSessionInbox()
|
|
309
|
+
|
|
258
310
|
// Fire UserPromptSubmit hooks before processing
|
|
259
311
|
if (this.hookEngine) {
|
|
260
|
-
const submitResult = await this.hookEngine.executeUserPromptSubmit(userInput,
|
|
312
|
+
const submitResult = await this.hookEngine.executeUserPromptSubmit(userInput, this.sessionId)
|
|
261
313
|
if (submitResult.additionalContext) {
|
|
262
314
|
this.context.addMessage({
|
|
263
315
|
role: 'user',
|
|
@@ -292,7 +344,7 @@ export class QueryEngine {
|
|
|
292
344
|
const model = this.registry.getActiveModel()
|
|
293
345
|
const request = buildRequest(
|
|
294
346
|
messages,
|
|
295
|
-
|
|
347
|
+
this.sessionId,
|
|
296
348
|
provider,
|
|
297
349
|
model,
|
|
298
350
|
hookConfig.organization_id,
|
|
@@ -587,7 +639,7 @@ export class QueryEngine {
|
|
|
587
639
|
const model = this.registry.getActiveModel()
|
|
588
640
|
const request = buildRequest(
|
|
589
641
|
messages,
|
|
590
|
-
|
|
642
|
+
this.sessionId,
|
|
591
643
|
provider,
|
|
592
644
|
model,
|
|
593
645
|
hookConfig.organization_id,
|
|
@@ -761,8 +813,9 @@ export class QueryEngine {
|
|
|
761
813
|
|
|
762
814
|
// Run PreToolUse hooks
|
|
763
815
|
let effectiveParams = params
|
|
816
|
+
let hookWarnings: string[] = []
|
|
764
817
|
if (this.hookEngine) {
|
|
765
|
-
const preResult = await this.hookEngine.executePreToolUse(name, params,
|
|
818
|
+
const preResult = await this.hookEngine.executePreToolUse(name, params, this.sessionId)
|
|
766
819
|
if (!preResult.allowed) {
|
|
767
820
|
return {
|
|
768
821
|
success: false,
|
|
@@ -775,10 +828,21 @@ export class QueryEngine {
|
|
|
775
828
|
}
|
|
776
829
|
}
|
|
777
830
|
|
|
831
|
+
// CRSI RuleEngine intercept — gated by crsi.preToolHook feature flag
|
|
832
|
+
if (this.crsiConfig?.preToolHook !== false && this.ruleEngine) {
|
|
833
|
+
const ruleResult = this.ruleEngine.intercept(name, effectiveParams)
|
|
834
|
+
if (Object.keys(ruleResult.modified).length > 0) {
|
|
835
|
+
effectiveParams = ruleResult.modified
|
|
836
|
+
}
|
|
837
|
+
if (ruleResult.warnings.length > 0) {
|
|
838
|
+
hookWarnings = ruleResult.warnings
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
778
842
|
try {
|
|
779
843
|
const result = await tool.execute(effectiveParams, {
|
|
780
844
|
cwd: process.cwd(),
|
|
781
|
-
sessionId:
|
|
845
|
+
sessionId: this.sessionId,
|
|
782
846
|
provider: this.registry.getActive().config.id,
|
|
783
847
|
model: this.registry.getActiveModel(),
|
|
784
848
|
skillsLoader: this.skillsLoader,
|
|
@@ -788,14 +852,33 @@ export class QueryEngine {
|
|
|
788
852
|
agentRegistry: this.agentRegistry,
|
|
789
853
|
backgroundAgentRegistry: getBackgroundAgentRegistry(),
|
|
790
854
|
permissionSystem: this.permission,
|
|
855
|
+
ruleEngine: this.ruleEngine,
|
|
791
856
|
})
|
|
792
857
|
|
|
793
858
|
// Track touched files for rules matching
|
|
794
859
|
this.trackTouchedFile(name, effectiveParams)
|
|
795
860
|
|
|
861
|
+
// Prepend CRSI warnings to result content
|
|
862
|
+
if (hookWarnings.length > 0 && result.success) {
|
|
863
|
+
result.content = hookWarnings.join('\n') + '\n' + (result.content || '')
|
|
864
|
+
}
|
|
865
|
+
|
|
796
866
|
// Run PostToolUse hooks
|
|
797
867
|
if (this.hookEngine) {
|
|
798
|
-
await this.hookEngine.executePostToolUse(name, effectiveParams, result,
|
|
868
|
+
await this.hookEngine.executePostToolUse(name, effectiveParams, result, this.sessionId)
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// CRSI: Track rule effectiveness after tool execution
|
|
872
|
+
if (hookWarnings.length > 0) {
|
|
873
|
+
const tracker = this.getEffectivenessTracker()
|
|
874
|
+
for (const warning of hookWarnings) {
|
|
875
|
+
const match = warning.match(/^\[rule:([^\]]+)\]/)
|
|
876
|
+
const ruleId = match?.[1]
|
|
877
|
+
if (ruleId) {
|
|
878
|
+
tracker.recordApplication(ruleId, result.success)
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
tracker.persist()
|
|
799
882
|
}
|
|
800
883
|
|
|
801
884
|
return result
|
|
@@ -829,6 +912,27 @@ export class QueryEngine {
|
|
|
829
912
|
return this.tools
|
|
830
913
|
}
|
|
831
914
|
|
|
915
|
+
getRuleEngine(): ExperienceRuleEngine | undefined {
|
|
916
|
+
return this.ruleEngine
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/** Lazily-initialized PatternAnalyzer singleton. */
|
|
920
|
+
getPatternAnalyzer(): PatternAnalyzer {
|
|
921
|
+
if (!this._patternAnalyzer) {
|
|
922
|
+
this._patternAnalyzer = new PatternAnalyzer()
|
|
923
|
+
}
|
|
924
|
+
return this._patternAnalyzer
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/** Lazily-initialized EffectivenessTracker singleton. */
|
|
928
|
+
getEffectivenessTracker(): EffectivenessTracker {
|
|
929
|
+
if (!this._effectivenessTracker) {
|
|
930
|
+
this._effectivenessTracker = new EffectivenessTracker()
|
|
931
|
+
this._effectivenessTracker.load()
|
|
932
|
+
}
|
|
933
|
+
return this._effectivenessTracker
|
|
934
|
+
}
|
|
935
|
+
|
|
832
936
|
/** Register a tool dynamically (used by MCP auto-registration). */
|
|
833
937
|
registerTool(tool: ToolDefinition): void {
|
|
834
938
|
if (this.tools.has(tool.name)) {
|
|
@@ -867,7 +971,7 @@ export class QueryEngine {
|
|
|
867
971
|
/** Wrap context compaction with PreCompact/PostCompact hooks. */
|
|
868
972
|
private async compactWithHooks(heading: string): Promise<void> {
|
|
869
973
|
if (this.hookEngine) {
|
|
870
|
-
const preResult = await this.hookEngine.executePreCompact(
|
|
974
|
+
const preResult = await this.hookEngine.executePreCompact(this.sessionId)
|
|
871
975
|
if (preResult.additionalContext) {
|
|
872
976
|
this.context.addMessage({
|
|
873
977
|
role: 'user',
|
|
@@ -879,7 +983,7 @@ export class QueryEngine {
|
|
|
879
983
|
await this.context.compact(heading)
|
|
880
984
|
|
|
881
985
|
if (this.hookEngine) {
|
|
882
|
-
const postResult = await this.hookEngine.executePostCompact(
|
|
986
|
+
const postResult = await this.hookEngine.executePostCompact(this.sessionId)
|
|
883
987
|
if (postResult.additionalContext) {
|
|
884
988
|
this.context.addMessage({
|
|
885
989
|
role: 'user',
|
|
@@ -893,7 +997,7 @@ export class QueryEngine {
|
|
|
893
997
|
private async *checkStopHook(signal?: AbortSignal): AsyncGenerator<StreamChunk> {
|
|
894
998
|
if (!this.hookEngine) return
|
|
895
999
|
|
|
896
|
-
const stopResult = await this.hookEngine.executeStop(
|
|
1000
|
+
const stopResult = await this.hookEngine.executeStop(this.sessionId)
|
|
897
1001
|
if (stopResult.decision === 'block') {
|
|
898
1002
|
// Feed the block reason back to the AI and continue
|
|
899
1003
|
this.context.addMessage({
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { ExperienceRule } from '../agent/experience-rules.js'
|
|
2
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'
|
|
3
|
+
import { join, dirname } from 'node:path'
|
|
4
|
+
|
|
5
|
+
export interface ToolRule {
|
|
6
|
+
id: string
|
|
7
|
+
toolName: string
|
|
8
|
+
category: string
|
|
9
|
+
match: (params: Record<string, unknown>) => boolean
|
|
10
|
+
fix: (params: Record<string, unknown>) => {
|
|
11
|
+
modified: Record<string, unknown>
|
|
12
|
+
warning: string
|
|
13
|
+
}
|
|
14
|
+
source: 'builtin' | 'pattern-analyzer' | 'manual'
|
|
15
|
+
enabled: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const BUILTIN_RULES: ToolRule[] = [
|
|
19
|
+
{
|
|
20
|
+
id: 'rule-timeout-bash-heavy',
|
|
21
|
+
toolName: 'Bash',
|
|
22
|
+
category: 'timeout',
|
|
23
|
+
match: (p: Record<string, unknown>) => {
|
|
24
|
+
const cmd = String(p.command ?? '')
|
|
25
|
+
const heavy = /npm (install|ci|test)|docker build|pnpm install|cargo build|brew install/.test(
|
|
26
|
+
cmd,
|
|
27
|
+
)
|
|
28
|
+
if (!heavy) return false
|
|
29
|
+
const timeout = (p as Record<string, unknown>).timeout as number | undefined
|
|
30
|
+
return !timeout || timeout < 300_000
|
|
31
|
+
},
|
|
32
|
+
fix: (p: Record<string, unknown>) => {
|
|
33
|
+
const prevTimeout = (p as Record<string, unknown>).timeout as number | undefined
|
|
34
|
+
return {
|
|
35
|
+
modified: { ...p, timeout: 300_000 },
|
|
36
|
+
warning: `⏱️ timeout 已从 ${prevTimeout || 'default'}ms 自动提升至 300000ms(该命令类型历史超时率 > 50%)`,
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
source: 'builtin',
|
|
40
|
+
enabled: true,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 'rule-git-force-protection',
|
|
44
|
+
toolName: 'Bash',
|
|
45
|
+
category: 'tool-params',
|
|
46
|
+
match: (p: Record<string, unknown>) => {
|
|
47
|
+
const cmd = String(p.command ?? '')
|
|
48
|
+
return /git (push|reset) .*--force/.test(cmd) && !p.dangerouslyDisableSandbox
|
|
49
|
+
},
|
|
50
|
+
fix: (p: Record<string, unknown>) => ({
|
|
51
|
+
modified: p,
|
|
52
|
+
warning: '⚠️ 检测到 git --force 操作。如需执行请设置 dangerouslyDisableSandbox: true',
|
|
53
|
+
}),
|
|
54
|
+
source: 'builtin',
|
|
55
|
+
enabled: true,
|
|
56
|
+
},
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
export class ExperienceRuleEngine {
|
|
60
|
+
private rules: ToolRule[]
|
|
61
|
+
private storePath: string
|
|
62
|
+
|
|
63
|
+
constructor(storeDir: string = join(process.env.HOME || '~', '.mipham', 'rule-engine')) {
|
|
64
|
+
this.rules = [...BUILTIN_RULES.map((r) => ({ ...r }))]
|
|
65
|
+
this.storePath = join(storeDir, 'rules.json')
|
|
66
|
+
this.load()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
register(rule: ToolRule): void {
|
|
70
|
+
// Replace if same ID exists, otherwise append
|
|
71
|
+
const idx = this.rules.findIndex((r) => r.id === rule.id)
|
|
72
|
+
if (idx !== -1) {
|
|
73
|
+
this.rules[idx] = rule
|
|
74
|
+
} else {
|
|
75
|
+
this.rules.push(rule)
|
|
76
|
+
}
|
|
77
|
+
this.persist()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
intercept(
|
|
81
|
+
toolName: string,
|
|
82
|
+
params: Record<string, unknown>,
|
|
83
|
+
): { modified: Record<string, unknown>; warnings: string[] } {
|
|
84
|
+
let modified = params
|
|
85
|
+
const warnings: string[] = []
|
|
86
|
+
|
|
87
|
+
for (const rule of this.rules) {
|
|
88
|
+
if (!rule.enabled) continue
|
|
89
|
+
if (rule.toolName !== toolName) continue
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
if (rule.match(modified)) {
|
|
93
|
+
const result = rule.fix(modified)
|
|
94
|
+
modified = result.modified
|
|
95
|
+
if (result.warning) {
|
|
96
|
+
warnings.push(`[rule:${rule.id}] ${result.warning}`)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
// Rule match/fix failures never block execution
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { modified, warnings }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
convertFromExperienceRules(experienceRules: ExperienceRule[]): ToolRule[] {
|
|
108
|
+
return experienceRules.map((er): ToolRule => {
|
|
109
|
+
// Map experience rule category to tool name
|
|
110
|
+
const toolNameMap: Record<string, string> = {
|
|
111
|
+
timeout: 'Bash',
|
|
112
|
+
'tool-params': 'Bash',
|
|
113
|
+
import: 'Write',
|
|
114
|
+
search: 'Grep',
|
|
115
|
+
semantic: 'Bash',
|
|
116
|
+
}
|
|
117
|
+
const toolName = toolNameMap[er.category] || 'Bash'
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
id: er.id,
|
|
121
|
+
toolName,
|
|
122
|
+
category: er.category,
|
|
123
|
+
match: (p: Record<string, unknown>): boolean => {
|
|
124
|
+
if (er.category === 'timeout') {
|
|
125
|
+
const cmd = String(p.command ?? '')
|
|
126
|
+
const heavy = /npm|docker|pnpm|cargo|brew|install|build/.test(cmd)
|
|
127
|
+
if (!heavy) return false
|
|
128
|
+
const timeout = (p as Record<string, unknown>).timeout as number | undefined
|
|
129
|
+
return !timeout || timeout < 300_000
|
|
130
|
+
}
|
|
131
|
+
// Default: always match for the given tool
|
|
132
|
+
return true
|
|
133
|
+
},
|
|
134
|
+
fix: (p: Record<string, unknown>) => ({
|
|
135
|
+
modified: { ...p, timeout: 300_000 },
|
|
136
|
+
warning: `⏱️ [auto-rule] ${er.action} (${er.evidence.failureCount} 次历史失败)`,
|
|
137
|
+
}),
|
|
138
|
+
source: 'pattern-analyzer',
|
|
139
|
+
enabled: true,
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
getActiveRules(): ToolRule[] {
|
|
145
|
+
return this.rules.filter((r) => r.enabled)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
setRuleEnabled(id: string, enabled: boolean): void {
|
|
149
|
+
const rule = this.rules.find((r) => r.id === id)
|
|
150
|
+
if (rule) {
|
|
151
|
+
rule.enabled = enabled
|
|
152
|
+
this.persist()
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Persist non-builtin rules to disk. */
|
|
157
|
+
persist(): void {
|
|
158
|
+
const nonBuiltin = this.rules.filter((r) => r.source !== 'builtin')
|
|
159
|
+
const dir = dirname(this.storePath)
|
|
160
|
+
mkdirSync(dir, { recursive: true })
|
|
161
|
+
writeFileSync(this.storePath, JSON.stringify(nonBuiltin, null, 2), 'utf-8')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Load persisted non-builtin rules from disk. Rejects rules whose IDs conflict with builtins. */
|
|
165
|
+
load(): void {
|
|
166
|
+
if (!existsSync(this.storePath)) return
|
|
167
|
+
try {
|
|
168
|
+
const raw = JSON.parse(readFileSync(this.storePath, 'utf-8')) as ToolRule[]
|
|
169
|
+
const builtinIds = new Set(BUILTIN_RULES.map((r) => r.id))
|
|
170
|
+
for (const rule of raw) {
|
|
171
|
+
// Reject if a builtin with the same ID exists (builtins always win)
|
|
172
|
+
if (builtinIds.has(rule.id)) continue
|
|
173
|
+
this.rules.push(rule)
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
// Corrupt file — start fresh with only builtins
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
statSync,
|
|
10
10
|
} from 'node:fs'
|
|
11
11
|
import { join } from 'node:path'
|
|
12
|
+
import { createHash } from 'node:crypto'
|
|
12
13
|
import type { Message } from '../shared/types'
|
|
13
14
|
|
|
14
15
|
export interface SessionMetadata {
|
|
@@ -50,7 +51,15 @@ function ensureDir(): void {
|
|
|
50
51
|
|
|
51
52
|
function filePath(name: string): string {
|
|
52
53
|
// Sanitize name for filesystem
|
|
53
|
-
const safe = name.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
54
|
+
const safe = name.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
55
|
+
|
|
56
|
+
// For very long names (>100 chars), use SHA-256 hash suffix to prevent collision
|
|
57
|
+
if (safe.length > 100) {
|
|
58
|
+
const hash = createHash('sha256').update(safe).digest('hex').slice(0, 16)
|
|
59
|
+
const truncated = safe.slice(0, 80)
|
|
60
|
+
return join(SESSIONS_DIR, `${truncated}-${hash}.jsonl`)
|
|
61
|
+
}
|
|
62
|
+
|
|
54
63
|
return join(SESSIONS_DIR, `${safe}.jsonl`)
|
|
55
64
|
}
|
|
56
65
|
|
|
@@ -233,7 +242,11 @@ export class SessionStore {
|
|
|
233
242
|
ensureDir()
|
|
234
243
|
mkdirSync(SUMMARIES_DIR, { recursive: true })
|
|
235
244
|
|
|
236
|
-
const
|
|
245
|
+
const safeRaw = name.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
246
|
+
const safe =
|
|
247
|
+
safeRaw.length > 100
|
|
248
|
+
? `${safeRaw.slice(0, 80)}-${createHash('sha256').update(safeRaw).digest('hex').slice(0, 16)}`
|
|
249
|
+
: safeRaw
|
|
237
250
|
const summaryPath = join(SUMMARIES_DIR, `${safe}.md`)
|
|
238
251
|
writeFileSync(summaryPath, `# ${name}\n\n${summary}\n\nTags: ${tags.join(', ')}\n`, 'utf-8')
|
|
239
252
|
|
package/src/index.tsx
CHANGED
|
@@ -3,12 +3,24 @@ import { join } from 'node:path'
|
|
|
3
3
|
import { existsSync } from 'node:fs'
|
|
4
4
|
import { render } from 'ink'
|
|
5
5
|
import { App } from './ui/app'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
loadConfig,
|
|
8
|
+
loadInferenceHookConfig,
|
|
9
|
+
loadCredentialMaskingConfig,
|
|
10
|
+
loadCrossSessionConfig,
|
|
11
|
+
} from './config/loader'
|
|
12
|
+
import {
|
|
13
|
+
registerActiveSession,
|
|
14
|
+
heartbeatSession,
|
|
15
|
+
unregisterSession,
|
|
16
|
+
createSessionInfo,
|
|
17
|
+
} from './agent/cross-session/discovery'
|
|
7
18
|
import { bootstrapProviders } from './providers/bootstrap'
|
|
8
19
|
import { InstructionsLoader } from './core/instructions'
|
|
9
20
|
import { loadSessionMemories, getMemoryManager } from './core/memory/memory-loader'
|
|
10
21
|
import { ContextManager } from './core/context'
|
|
11
22
|
import { QueryEngine } from './core/engine'
|
|
23
|
+
import { ExperienceRuleEngine } from './core/rule-engine.js'
|
|
12
24
|
import { SessionStore } from './core/session-store'
|
|
13
25
|
import type { PermissionLevel } from './shared/types'
|
|
14
26
|
import { SkillsLoader } from './skills/loader'
|
|
@@ -236,7 +248,8 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
236
248
|
const artifactServer = new ArtifactServer(artifactsDir, ARTIFACT_PORT)
|
|
237
249
|
|
|
238
250
|
// Create query engine
|
|
239
|
-
const
|
|
251
|
+
const ruleEngine = new ExperienceRuleEngine()
|
|
252
|
+
const engine = new QueryEngine(registry, context, tools, undefined, ruleEngine)
|
|
240
253
|
engine.setHookEngine(hookEngine)
|
|
241
254
|
engine.setArtifactServer(artifactServer)
|
|
242
255
|
engine.setAgentViewManager(agentViewManager)
|
|
@@ -246,6 +259,10 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
246
259
|
const inferenceHookConfig = loadInferenceHookConfig()
|
|
247
260
|
engine.setInferenceHookConfig(inferenceHookConfig)
|
|
248
261
|
|
|
262
|
+
// Initialize credential masking pipeline (strategies: Full, Extract, JWT)
|
|
263
|
+
const { initializePipeline } = await import('./core/credential-masker/index')
|
|
264
|
+
initializePipeline()
|
|
265
|
+
|
|
249
266
|
// Wire credential masking configuration into tools
|
|
250
267
|
const credentialMaskingConfig = loadCredentialMaskingConfig()
|
|
251
268
|
const { setCredentialMaskingConfigForRead } = await import('./tools/file/read')
|
|
@@ -280,10 +297,36 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
280
297
|
|
|
281
298
|
engine.setupContextSummarizer()
|
|
282
299
|
|
|
300
|
+
// ── Cross-session messaging: register session, heartbeat, shutdown ──
|
|
301
|
+
engine.setSessionId(sessionName)
|
|
302
|
+
const sessionInfo = createSessionInfo(
|
|
303
|
+
sessionName,
|
|
304
|
+
sessionName,
|
|
305
|
+
process.cwd(),
|
|
306
|
+
defaultProvider,
|
|
307
|
+
defaultModel,
|
|
308
|
+
)
|
|
309
|
+
registerActiveSession(sessionInfo)
|
|
310
|
+
|
|
311
|
+
// 30-second heartbeat to keep the session file mtime fresh
|
|
312
|
+
const heartbeatInterval = setInterval(() => {
|
|
313
|
+
heartbeatSession(sessionName)
|
|
314
|
+
}, 30_000)
|
|
315
|
+
|
|
316
|
+
// Allow heartbeat to not keep the process alive
|
|
317
|
+
if (heartbeatInterval.unref) {
|
|
318
|
+
heartbeatInterval.unref()
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Load cross-session config (available for MessageRouter and future consumers)
|
|
322
|
+
loadCrossSessionConfig()
|
|
323
|
+
|
|
283
324
|
// Auto-save session on exit
|
|
284
325
|
let saved = false
|
|
285
326
|
const saveAndExit = () => {
|
|
286
327
|
saved = true
|
|
328
|
+
clearInterval(heartbeatInterval)
|
|
329
|
+
unregisterSession(sessionName)
|
|
287
330
|
artifactServer.stop()
|
|
288
331
|
if (context.getMessageCount() > 0) {
|
|
289
332
|
SessionStore.save(sessionName, context.getMessages(), {
|
|
@@ -300,6 +343,8 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
300
343
|
|
|
301
344
|
// Safety net: auto-save on exit for paths that bypass saveAndExit
|
|
302
345
|
process.on('exit', () => {
|
|
346
|
+
clearInterval(heartbeatInterval)
|
|
347
|
+
unregisterSession(sessionName)
|
|
303
348
|
if (!saved && context.getMessageCount() > 0) {
|
|
304
349
|
SessionStore.autoSave(context.getMessages(), {
|
|
305
350
|
provider: defaultProvider,
|
package/src/shared/types.ts
CHANGED
|
@@ -72,6 +72,7 @@ export interface ToolContext {
|
|
|
72
72
|
agentRegistry?: import('../agent/agent-registry').AgentRegistry
|
|
73
73
|
backgroundAgentRegistry?: import('../agent/background-registry').BackgroundAgentRegistry
|
|
74
74
|
permissionSystem?: import('../core/permission').PermissionSystem
|
|
75
|
+
ruleEngine?: import('../core/rule-engine').ExperienceRuleEngine
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
// ── Artifact Types ──
|
|
@@ -162,6 +163,8 @@ export interface MiphamConfig {
|
|
|
162
163
|
}
|
|
163
164
|
/** Phase 9 feature flags. All default to true. */
|
|
164
165
|
features?: Partial<FeatureFlags>
|
|
166
|
+
/** Phase 10 CRSI feature flags. All default to true. */
|
|
167
|
+
crsi?: Partial<CrsiConfig>
|
|
165
168
|
}
|
|
166
169
|
|
|
167
170
|
export interface FeatureFlags {
|
|
@@ -169,6 +172,17 @@ export interface FeatureFlags {
|
|
|
169
172
|
context: { useRealTokenizer: boolean; adaptiveThresholds: boolean }
|
|
170
173
|
}
|
|
171
174
|
|
|
175
|
+
export interface CrsiConfig {
|
|
176
|
+
/** Extract and inject experience rules into agent system prompts. */
|
|
177
|
+
ruleInjection: boolean
|
|
178
|
+
/** Intercept tool calls and auto-fix known failure patterns before execution. */
|
|
179
|
+
preToolHook: boolean
|
|
180
|
+
/** Analyze agent outcomes for recurring failure patterns. */
|
|
181
|
+
autoPatternAnalysis: boolean
|
|
182
|
+
/** Auto-degrade/disable low-effectiveness rules based on success-rate tracking. */
|
|
183
|
+
autoRuleManagement: boolean
|
|
184
|
+
}
|
|
185
|
+
|
|
172
186
|
export interface McpServerConfig {
|
|
173
187
|
name: string
|
|
174
188
|
command: string
|
|
@@ -375,17 +389,44 @@ export interface CredentialFullMaskRule {
|
|
|
375
389
|
mode: 'full'
|
|
376
390
|
}
|
|
377
391
|
|
|
392
|
+
/** Per-extract-pattern configuration with optional field-based extraction. */
|
|
393
|
+
export interface CredentialExtractPattern {
|
|
394
|
+
/** Regex pattern to match. When `field` is set, applied to field value only. */
|
|
395
|
+
pattern: string
|
|
396
|
+
/** Optional replacement string. Defaults to CREDENTIAL_SENTINEL. */
|
|
397
|
+
replacement?: string
|
|
398
|
+
/** 🆕 JSON key to extract before applying pattern. If unset, pattern runs against full content. */
|
|
399
|
+
field?: string
|
|
400
|
+
}
|
|
401
|
+
|
|
378
402
|
/** Extract-based masking rule: only regex-matched tokens are replaced. */
|
|
379
403
|
export interface CredentialExtractRule {
|
|
380
404
|
path: string
|
|
381
405
|
mode: 'extract'
|
|
382
|
-
extract:
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
406
|
+
extract: CredentialExtractPattern[]
|
|
407
|
+
/** 🆕 Behavior when no extract pattern matches: 'mask' (replace all) or 'passthrough' (keep). Default: 'mask'. */
|
|
408
|
+
onExtractNoMatch?: 'mask' | 'passthrough'
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** 🆕 JWT-aware masking rule: decode payload and mask specified claims. */
|
|
412
|
+
export interface JwtMaskingRule {
|
|
413
|
+
path: string
|
|
414
|
+
type: 'jwt'
|
|
415
|
+
decode: 'jwt'
|
|
416
|
+
/** Claim names to mask in the JWT payload (e.g. ["sub", "email"]). */
|
|
417
|
+
maskClaims: string[]
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** 🆕 AWS credential pair masking rule: detect key pairs and optionally re-sign. */
|
|
421
|
+
export interface AwsMaskingRule {
|
|
422
|
+
path: string
|
|
423
|
+
type: 'aws'
|
|
424
|
+
awsPairs: boolean
|
|
425
|
+
sigv4: boolean
|
|
386
426
|
}
|
|
387
427
|
|
|
388
|
-
export type CredentialFileRule =
|
|
428
|
+
export type CredentialFileRule =
|
|
429
|
+
CredentialFullMaskRule | CredentialExtractRule | JwtMaskingRule | AwsMaskingRule
|
|
389
430
|
|
|
390
431
|
/** Configuration for credential masking, loaded from config.yml. */
|
|
391
432
|
export interface CredentialMaskingConfig {
|
|
@@ -401,6 +442,29 @@ export interface CredentialMaskingConfig {
|
|
|
401
442
|
}
|
|
402
443
|
}
|
|
403
444
|
|
|
445
|
+
// ── Cross-Session Messaging Types ──
|
|
446
|
+
|
|
447
|
+
/** Controls how inbound cross-session messages are handled. */
|
|
448
|
+
export type CrossSessionInbound = 'allow' | 'ask' | 'deny'
|
|
449
|
+
|
|
450
|
+
/** 🆕 Configuration for cross-session messaging. */
|
|
451
|
+
export interface CrossSessionConfig {
|
|
452
|
+
crossSessionInbound: CrossSessionInbound
|
|
453
|
+
dialogExpiry: number // seconds
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Session information exposed via ListAgents. */
|
|
457
|
+
export interface SessionInfo {
|
|
458
|
+
id: string
|
|
459
|
+
name: string
|
|
460
|
+
machine: string
|
|
461
|
+
pid: number
|
|
462
|
+
startedAt: string
|
|
463
|
+
cwd?: string
|
|
464
|
+
provider?: string
|
|
465
|
+
model?: string
|
|
466
|
+
}
|
|
467
|
+
|
|
404
468
|
// ── Background Agent Types ──
|
|
405
469
|
|
|
406
470
|
export interface BackgroundAgentConfig {
|
package/src/tools/agent/agent.ts
CHANGED
|
@@ -60,7 +60,13 @@ export const agentTool: ToolDefinition = {
|
|
|
60
60
|
const agentDef = ctx.agentRegistry?.resolve(agentType)
|
|
61
61
|
|
|
62
62
|
try {
|
|
63
|
-
const sub = new SubAgent(
|
|
63
|
+
const sub = new SubAgent(
|
|
64
|
+
registry,
|
|
65
|
+
toolRegistry,
|
|
66
|
+
ctx.permissionSystem,
|
|
67
|
+
undefined,
|
|
68
|
+
ctx.ruleEngine,
|
|
69
|
+
)
|
|
64
70
|
const result = await sub.execute(prompt, description, {
|
|
65
71
|
type: agentType,
|
|
66
72
|
agentDef,
|