foliko 1.1.75 → 1.1.76

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.
Files changed (50) hide show
  1. package/.claude/settings.local.json +5 -1
  2. package/.dockerignore +45 -45
  3. package/.env.example +56 -56
  4. package/cli/src/ui/chat-ui.js +56 -126
  5. package/cli/src/ui/components/agent-mention-provider.js +175 -0
  6. package/cli/src/ui/components/chained-autocomplete-provider.js +64 -0
  7. package/cli/src/ui/{footer-bar.js → components/footer-bar.js} +2 -2
  8. package/docker-compose.yml +33 -33
  9. package/docs/features.md +120 -120
  10. package/docs/quick-reference.md +160 -160
  11. package/package.json +1 -1
  12. package/plugins/ai-plugin.js +3 -3
  13. package/plugins/ambient-agent/ExplorerLoop.js +17 -17
  14. package/plugins/ambient-agent/index.js +2 -2
  15. package/plugins/data-splitter-plugin.js +9 -9
  16. package/plugins/default-plugins.js +7 -8
  17. package/plugins/extension-executor-plugin.js +2 -2
  18. package/plugins/feishu-plugin.js +5 -5
  19. package/plugins/install-plugin.js +4 -4
  20. package/plugins/memory-plugin.js +1 -1
  21. package/plugins/plugin-manager-plugin.js +9 -8
  22. package/plugins/python-plugin-loader.js +2 -2
  23. package/plugins/qq-plugin.js +4 -4
  24. package/plugins/rules-plugin.js +2 -2
  25. package/plugins/scheduler-plugin.js +13 -13
  26. package/plugins/session-plugin.js +2 -2
  27. package/plugins/subagent-plugin.js +1 -1
  28. package/plugins/telegram-plugin.js +6 -6
  29. package/plugins/think-plugin.js +4 -4
  30. package/plugins/tools-plugin.js +1 -1
  31. package/plugins/web-plugin.js +16 -16
  32. package/skills/find-skills/SKILL.md +133 -133
  33. package/skills/foliko-dev/AGENTS.md +236 -236
  34. package/skills/mcp-usage/SKILL.md +200 -200
  35. package/skills/subagent-guide/SKILL.md +237 -237
  36. package/skills/workflow-guide/SKILL.md +646 -646
  37. package/src/capabilities/skill-manager.js +5 -5
  38. package/src/capabilities/workflow-engine.js +5 -5
  39. package/src/core/agent-chat.js +8 -8
  40. package/src/core/agent.js +80 -5
  41. package/src/core/branch-summary-auto.js +4 -4
  42. package/src/core/chat-session.js +1 -0
  43. package/src/core/session-entry.js +14 -6
  44. package/src/core/session-manager.js +15 -3
  45. package/src/executors/mcp-executor.js +21 -25
  46. package/src/utils/data-splitter.js +3 -3
  47. package/src/utils/message-validator.js +1 -1
  48. package/website_v2/styles/animations.css +7 -7
  49. /package/cli/src/ui/{message-bubble.js → components/message-bubble.js} +0 -0
  50. /package/cli/src/ui/{status-bar.js → components/status-bar.js} +0 -0
@@ -230,7 +230,11 @@
230
230
  "Bash(grep -r \"logger\\\\.\" D:/date/20260516/pi/packages --include=\"*.ts\" ! -path \"*/node_modules/*\" ! -path \"*/test/*\")",
231
231
  "Bash(grep *)",
232
232
  "Bash(node -e \"require\\('./plugins/audit-plugin.js'\\); console.log\\('audit-plugin loaded'\\)\")",
233
- "Bash(node -e \"const { createAI, DEFAULT_PROVIDERS } = require\\('./src/core/provider'\\); console.log\\('Providers:', Object.keys\\(DEFAULT_PROVIDERS\\)\\);\")"
233
+ "Bash(node -e \"const { createAI, DEFAULT_PROVIDERS } = require\\('./src/core/provider'\\); console.log\\('Providers:', Object.keys\\(DEFAULT_PROVIDERS\\)\\);\")",
234
+ "Bash(npx designmd-mcp *)",
235
+ "Bash(node -e \"const { spawn } = require\\('child_process'\\); const p = spawn\\('npx', ['designmd-mcp'], { stdio: 'pipe' }\\); let out = ''; p.stdout.on\\('data', \\(d\\) => { out += d.toString\\(\\); if\\(out.length > 1000\\) { console.log\\('OUTPUT_TOO_LONG:' + out.length\\); process.exit\\(0\\); } }\\); p.stderr.on\\('data', \\(d\\) => console.log\\('STDERR:' + d.toString\\(\\)\\)\\); setTimeout\\(\\(\\) => { console.log\\('FINAL_OUTPUT_LENGTH:' + out.length\\); process.exit\\(0\\); }, 3000\\);\")",
236
+ "Bash(where npx *)",
237
+ "Bash(node *)"
234
238
  ]
235
239
  }
236
240
  }
package/.dockerignore CHANGED
@@ -1,45 +1,45 @@
1
- # 依赖
2
- node_modules
3
- .foliko/node_modules
4
-
5
- # 日志
6
- *.log
7
- npm-debug.log*
8
-
9
- # 环境变量(包含敏感信息)
10
- .env
11
- .env.local
12
-
13
- # Git
14
- .git
15
- .gitignore
16
-
17
- # IDE
18
- .idea
19
- .vscode
20
- *.swp
21
- *.swo
22
-
23
- # 文档(可选保留)
24
- *.md
25
- !README.md
26
-
27
- # 测试
28
- test
29
- tests
30
- coverage
31
-
32
- # 临时文件
33
- tmp
34
- temp
35
- *.tmp
36
-
37
- # Docker 相关(避免递归)
38
- Dockerfile*
39
- docker-compose*
40
- .dockerignore
41
-
42
- # CI/CD
43
- .github
44
- .gitlab-ci.yml
45
- .travis.yml
1
+ # 依赖
2
+ node_modules
3
+ .foliko/node_modules
4
+
5
+ # 日志
6
+ *.log
7
+ npm-debug.log*
8
+
9
+ # 环境变量(包含敏感信息)
10
+ .env
11
+ .env.local
12
+
13
+ # Git
14
+ .git
15
+ .gitignore
16
+
17
+ # IDE
18
+ .idea
19
+ .vscode
20
+ *.swp
21
+ *.swo
22
+
23
+ # 文档(可选保留)
24
+ *.md
25
+ !README.md
26
+
27
+ # 测试
28
+ test
29
+ tests
30
+ coverage
31
+
32
+ # 临时文件
33
+ tmp
34
+ temp
35
+ *.tmp
36
+
37
+ # Docker 相关(避免递归)
38
+ Dockerfile*
39
+ docker-compose*
40
+ .dockerignore
41
+
42
+ # CI/CD
43
+ .github
44
+ .gitlab-ci.yml
45
+ .travis.yml
package/.env.example CHANGED
@@ -1,56 +1,56 @@
1
- # ========== AI Configuration ==========
2
- # 最大输出 tokens(影响 AI 回复长度)
3
- MAX_OUTPUT_TOKENS=8192
4
- # AI Provider: minimax, deepseek, openai, anthropic 等
5
- FOLIKO_PROVIDER=minimax
6
-
7
- # AI Model(如果未设置,使用 provider 默认值)
8
- # MiniMax: MiniMax-M2.7
9
- # DeepSeek: deepseek-chat, deepseek-coder 等
10
- FOLIKO_MODEL=MiniMax-M2.7
11
-
12
- # API Base URL(如果未设置,使用 provider 默认值)
13
- # MiniMax: https://api.minimaxi.com/v1
14
- # DeepSeek: https://api.deepseek.com/v1
15
- FOLIKO_BASE_URL=https://api.minimaxi.com/v1
16
-
17
- # API Key(通用,如果未设置则尝试 provider 专用 key)
18
- FOLIKO_API_KEY=sk-your-api-key
19
-
20
- # Provider 专用 API Key(可选,如果 FOLIKO_API_KEY 未设置则使用这些)
21
- DEEPSEEK_API_KEY=sk-your-deepseek-api-key
22
- MINIMAX_API_KEY=sk-your-minimax-api-key
23
-
24
- # ========== Email Configuration ==========
25
- # SMTP Settings (for sending emails)
26
- SMTP_HOST=smtp.gmail.com
27
- SMTP_PORT=587
28
- SMTP_SECURE=false
29
- SMTP_USER=your-email@gmail.com
30
- SMTP_PASS=your-app-password
31
-
32
- # IMAP Settings (for reading emails)
33
- IMAP_HOST=imap.gmail.com
34
- IMAP_PORT=993
35
- IMAP_USER=your-email@gmail.com
36
- IMAP_PASS=your-app-password
37
-
38
- # Default sender email address
39
- FROM_EMAIL=your-email@gmail.com
40
-
41
- # ========== Telegram Bot (optional) ==========
42
- TELEGRAM_BOT_TOKEN=your-telegram-bot-token
43
-
44
- # ========== Feishu Bot (optional) ==========
45
- FEISHU_APP_ID=cli_xxxxxxxxxxx
46
- FEISHU_APP_SECRET=app_secret
47
-
48
- # ========== Web Server (optional) ==========
49
- # Web 服务端口,默认 8088
50
- WEB_PORT=3000
51
-
52
- # Web 服务主机,默认 127.0.0.1
53
- WEB_HOST=127.0.0.1
54
-
55
- # 公网访问的 base URL(用于生成 webhook URL 等),不设置则使用 host:port,最好部署在docker中可以暴露自定义域名
56
- WEB_BASE_URL=https://your-domain.com
1
+ # ========== AI Configuration ==========
2
+ # 最大输出 tokens(影响 AI 回复长度)
3
+ MAX_OUTPUT_TOKENS=8192
4
+ # AI Provider: minimax, deepseek, openai, anthropic 等
5
+ FOLIKO_PROVIDER=minimax
6
+
7
+ # AI Model(如果未设置,使用 provider 默认值)
8
+ # MiniMax: MiniMax-M2.7
9
+ # DeepSeek: deepseek-chat, deepseek-coder 等
10
+ FOLIKO_MODEL=MiniMax-M2.7
11
+
12
+ # API Base URL(如果未设置,使用 provider 默认值)
13
+ # MiniMax: https://api.minimaxi.com/v1
14
+ # DeepSeek: https://api.deepseek.com/v1
15
+ FOLIKO_BASE_URL=https://api.minimaxi.com/v1
16
+
17
+ # API Key(通用,如果未设置则尝试 provider 专用 key)
18
+ FOLIKO_API_KEY=sk-your-api-key
19
+
20
+ # Provider 专用 API Key(可选,如果 FOLIKO_API_KEY 未设置则使用这些)
21
+ DEEPSEEK_API_KEY=sk-your-deepseek-api-key
22
+ MINIMAX_API_KEY=sk-your-minimax-api-key
23
+
24
+ # ========== Email Configuration ==========
25
+ # SMTP Settings (for sending emails)
26
+ SMTP_HOST=smtp.gmail.com
27
+ SMTP_PORT=587
28
+ SMTP_SECURE=false
29
+ SMTP_USER=your-email@gmail.com
30
+ SMTP_PASS=your-app-password
31
+
32
+ # IMAP Settings (for reading emails)
33
+ IMAP_HOST=imap.gmail.com
34
+ IMAP_PORT=993
35
+ IMAP_USER=your-email@gmail.com
36
+ IMAP_PASS=your-app-password
37
+
38
+ # Default sender email address
39
+ FROM_EMAIL=your-email@gmail.com
40
+
41
+ # ========== Telegram Bot (optional) ==========
42
+ TELEGRAM_BOT_TOKEN=your-telegram-bot-token
43
+
44
+ # ========== Feishu Bot (optional) ==========
45
+ FEISHU_APP_ID=cli_xxxxxxxxxxx
46
+ FEISHU_APP_SECRET=app_secret
47
+
48
+ # ========== Web Server (optional) ==========
49
+ # Web 服务端口,默认 8088
50
+ WEB_PORT=3000
51
+
52
+ # Web 服务主机,默认 127.0.0.1
53
+ WEB_HOST=127.0.0.1
54
+
55
+ # 公网访问的 base URL(用于生成 webhook URL 等),不设置则使用 host:port,最好部署在docker中可以暴露自定义域名
56
+ WEB_BASE_URL=https://your-domain.com
@@ -9,12 +9,14 @@ const { TUI, ProcessTerminal, Editor, Text, CombinedAutocompleteProvider, matche
9
9
  const { cleanResponse, logger } = require('../../../src/utils');
10
10
  const { logEmitter } = require('../../../src/utils/logger');
11
11
  const { renderLine } = require('../utils/markdown');
12
- const { MessageBubble } = require('./message-bubble');
12
+ const { MessageBubble } = require('./components/message-bubble');
13
13
  const Queue=require('js-queue');
14
14
  const hl = require('cli-highlight');
15
15
  const { renderDiffWithHeader } = require('../utils/render-diff');
16
- const { FooterBar } = require('./footer-bar');
17
- const { StatusBar } = require('./status-bar');
16
+ const { FooterBar } = require('./components/footer-bar');
17
+ const { StatusBar } = require('./components/status-bar');
18
+ const { AgentMentionProvider } = require('./components/agent-mention-provider');
19
+ const { ChainedAutocompleteProvider } = require('./components/chained-autocomplete-provider');
18
20
  const queue=new Queue();
19
21
  // Foliko 主色(蓝绿)
20
22
  const folikoPrimary = chalk.hex('#2A9D8F');
@@ -103,10 +105,12 @@ class ChatUI {
103
105
  { name: "reload", description: "重载技能" },
104
106
  ];
105
107
 
106
- this.autocompleteProvider = new CombinedAutocompleteProvider(
107
- [...baseCommands, ...this.skillCommands],
108
- process.cwd(),
109
- );
108
+ this.autocompleteProvider = new ChainedAutocompleteProvider([
109
+ // 优先拦截 @子Agent 补全
110
+ new AgentMentionProvider(agent.framework),
111
+ // 文件路径 / slash 命令补全
112
+ new CombinedAutocompleteProvider([...baseCommands, ...this.skillCommands], process.cwd()),
113
+ ]);
110
114
 
111
115
  const editor=this.editor = new Editor(this.tui, editorTheme,{paddingX:1});
112
116
  this.editor.setAutocompleteProvider(this.autocompleteProvider);
@@ -192,13 +196,13 @@ class ChatUI {
192
196
  ERROR: 'red',
193
197
  }
194
198
  // 监听错误日志,显示到 tooler
195
- // this._logHandler = (data) => {
196
- // if(!Object.keys(level_dict).includes(data.level))return;
197
- // const level=folikoTerracotta(`[${data.level}]`)
198
- // const level_key=level_dict[data.level]||'dim'
199
- // this.statusBar.notifier.show(`${level} ${chalk[level_key](data.message)}`)
200
- // };
201
- // logger.on('log', this._logHandler);
199
+ this._logHandler = (data) => {
200
+ if(!Object.keys(level_dict).includes(data.level))return;
201
+ const level=folikoTerracotta(`[${data.level}]`)
202
+ const level_key=level_dict[data.level]||'dim'
203
+ this.statusBar.notifier.show(`${level} ${chalk[level_key](data.message)}`)
204
+ };
205
+ logger.on('log', this._logHandler);
202
206
 
203
207
  // 监听通知事件,显示到通知区域
204
208
  this._notificationHandler = (data) => {
@@ -455,62 +459,20 @@ class ChatUI {
455
459
 
456
460
 
457
461
  _clearContext() {
458
- // 隐藏 loader 和 tooler
459
- this.statusBar.tooler.hide();
460
- this.statusBar.loader.hide();
462
+
461
463
 
462
464
  const { sessionId } = this;
465
+ this.agent.clearContext(sessionId);
463
466
 
464
- // Clear ChatSession message store (memory + file)
465
- if (this.agent._chatSession) {
466
- this.agent._chatSession.clearSessionMessages(sessionId, true);
467
- }
468
-
469
- // 1. 清除 AgentChatHandler 的消息存储(需要传 sessionId)
470
- if (this.agent._chatHandler) {
471
- this.agent._chatHandler.clearHistory(sessionId);
472
- }
473
-
474
- // 2. 清除 SessionContext 的消息和压缩计数
475
- if (this.agent.framework) {
476
- const sessionCtx = this.agent.framework.getSessionContext(sessionId);
477
- if (sessionCtx) {
478
- sessionCtx.clearMessages();
479
- if (sessionCtx.compressionState) {
480
- sessionCtx.compressionState.count = 0;
481
- }
482
- if (sessionCtx.metadata) {
483
- sessionCtx.metadata.compressionCount = 0;
484
- }
485
- }
486
- // 同步清除 framework 缓存的 sessionContexts
487
- const cachedCtx = this.agent.framework._sessionContexts?.get(sessionId);
488
- if (cachedCtx) {
489
- cachedCtx.clearMessages();
490
- if (cachedCtx.compressionState) {
491
- cachedCtx.compressionState.count = 0;
492
- }
493
- if (cachedCtx.metadata) {
494
- cachedCtx.metadata.compressionCount = 0;
495
- }
496
- }
497
- // 清除 SessionPlugin 中的消息
498
- if (this.sessionPlugin) {
499
- const manager = this.sessionPlugin._getSessionManager(sessionId);
500
- if (manager) {
501
- manager.clearMessages();
502
- }
503
- }
467
+ if (this.sessionPlugin) {
468
+ const manager = this.sessionPlugin._getSessionManager(sessionId);
469
+ if (manager) manager.clearMessages();
504
470
  }
505
471
 
506
- // 清空 messageContainer 中的消息,保留 welcome
507
472
  const welcomeChild = this.messageContainer.children[0];
508
473
  this.messageContainer.clear();
509
- if (welcomeChild) {
510
- this.messageContainer.addChild(welcomeChild);
511
- }
474
+ if (welcomeChild) this.messageContainer.addChild(welcomeChild);
512
475
 
513
- // 重置 footer 统计
514
476
  if (this.footerBar) {
515
477
  this.footerBar.reset();
516
478
  const footerLines = this.footerBar.render(this.tui.width || 80);
@@ -518,72 +480,37 @@ class ChatUI {
518
480
  this._footerText.setText(footerLines[0]);
519
481
  }
520
482
  }
483
+ this.statusBar.tooler.hide();
484
+ this.statusBar.loader.hide();
521
485
  this.tui.requestRender();
522
486
  }
523
487
 
524
488
  _compressContext() {
525
- // 隐藏 loader 和 tooler
526
- this.statusBar.tooler.hide();
527
- this.statusBar.loader.hide();
489
+
528
490
 
529
491
  const { sessionId } = this;
530
-
531
- // 调用 AgentChatHandler 的手动压缩
532
- if (this.agent._chatHandler) {
533
- const chatHandler = this.agent._chatHandler;
534
-
535
- // 先确保从 SessionContext 加载历史(强制重新加载)
536
- const messageStore = chatHandler._chatSession.getSessionMessageStore(sessionId);
537
- messageStore.historyLoaded = false; // 强制重新加载
538
- chatHandler._chatSession.loadHistory(sessionId);
539
-
540
- let messages = messageStore.messages;
541
-
542
- // 如果 messageStore.messages 为空,尝试从 SessionContext 获取
543
- if (!messages || messages.length === 0) {
544
- if (this.agent.framework) {
545
- const sessionCtx = this.agent.framework.getSessionContext(sessionId);
546
- if (sessionCtx) {
547
- messages = sessionCtx.getMessages();
548
- // 同步到 messageStore
549
- messageStore.messages = messages;
550
- }
551
- }
552
- }
553
-
554
- const beforeCount = messages ? messages.length : 0;
555
- this.statusBar.tooler.show(`${colored('[压缩]', YELLOW)} 压缩前: ${beforeCount} 条`);
556
-
557
- if (messages && messages.length > 0) {
558
- // 使用 ContextCompressor 压缩
559
- if (chatHandler._contextCompressor) {
560
- chatHandler._contextCompressor.compress(sessionId, messages, messageStore).then(() => {
561
- const afterCount = messages.length;
562
- this.statusBar.tooler.setText(`${colored('[压缩]', YELLOW)} 压缩后: ${afterCount} 条 (保留${chatHandler._contextCompressor._keepRecentMessages}条)`);
563
- // 同步回 SessionContext
564
- if (this.agent.framework) {
565
- const sessionCtx = this.agent.framework.getSessionContext(sessionId);
566
- if (sessionCtx) {
567
- sessionCtx.replaceMessages(messages);
568
- }
569
- }
570
- setTimeout(() => {
571
- this.statusBar.tooler.hide();
572
- this.create_message(`${colored('[提示]', CYAN)} 上下文已压缩`,chalk.dim('● '))
573
- this.tui.requestRender();
574
- }, 2000);
575
- }).catch(err => {
576
- this.statusBar.tooler.setText(`${colored('[错误]', RED)} 压缩失败: ${err.message}`);
577
- });
578
- } else {
579
- this.statusBar.tooler.setText(`${colored('[错误]', RED)} 无 ContextCompressor`);
580
- }
581
- } else {
492
+ this.statusBar.tooler.show(`${colored('[压缩]', YELLOW)} 压缩中...`);
493
+
494
+ this.agent.compressContext(sessionId).then(({ before, after, keepRecent }) => {
495
+ this.statusBar.tooler.setText(
496
+ `${colored('[压缩]', YELLOW)} 压缩后: ${after} 条 (保留${keepRecent}条)`
497
+ );
498
+ setTimeout(() => {
499
+ this.statusBar.tooler.hide();
500
+ this.statusBar.loader.hide();
501
+ this.create_message(`${colored('[提示]', CYAN)} 上下文已压缩`, chalk.dim('● '));
502
+ this.tui.requestRender();
503
+ }, 2000);
504
+ }).catch(err => {
505
+ this.statusBar.tooler.hide();
506
+ this.statusBar.loader.hide();
507
+ const msg = err.message || String(err);
508
+ if (msg.includes('No messages')) {
582
509
  this.statusBar.tooler.setText(`${colored('[提示]', CYAN)} 无消息可压缩`);
510
+ } else {
511
+ this.statusBar.tooler.setText(`${colored('[错误]', RED)} ${msg}`);
583
512
  }
584
- } else {
585
- this.statusBar.tooler.setText(`${colored('[错误]', RED)} 无 AgentChatHandler`);
586
- }
513
+ });
587
514
  }
588
515
  /**
589
516
  * 刷新 skill 命令列表(用于热重载)
@@ -602,13 +529,16 @@ class ChatUI {
602
529
  */
603
530
  _refreshAutocomplete() {
604
531
  if (!this.editor) return;
605
- ;
606
- // 重新构建 CombinedAutocompleteProvider 并更新引用
532
+
533
+ // 重新构建 ChainedAutocompleteProvider(含 AgentMentionProvider)
607
534
  const { CombinedAutocompleteProvider } = require('@earendil-works/pi-tui');
608
- this.autocompleteProvider = new CombinedAutocompleteProvider(
609
- [...this.baseCommands, ...this.skillCommands],
610
- process.cwd()
611
- );
535
+ const { AgentMentionProvider } = require('./components/agent-mention-provider');
536
+ const { ChainedAutocompleteProvider } = require('./components/chained-autocomplete-provider');
537
+
538
+ this.autocompleteProvider = new ChainedAutocompleteProvider([
539
+ new AgentMentionProvider(this.agent.framework),
540
+ new CombinedAutocompleteProvider([...this.baseCommands, ...this.skillCommands], process.cwd()),
541
+ ]);
612
542
  this.editor.setAutocompleteProvider(this.autocompleteProvider);
613
543
  }
614
544
  /**
@@ -0,0 +1,175 @@
1
+ /**
2
+ * AgentMentionProvider — @子Agent 提及补全提供者
3
+ *
4
+ * 处理 @agent_name 语法,在输入 @ 时触发子Agent 列表补全。
5
+ * 实现 AutocompleteProvider 接口,与 CombinedAutocompleteProvider 协同工作。
6
+ */
7
+
8
+ const { fuzzyFilter } = require('@earendil-works/pi-tui/dist/fuzzy.js');
9
+
10
+ /**
11
+ * @typedef {Object} AutocompleteItem
12
+ * @property {string} value
13
+ * @property {string} label
14
+ * @property {string} [description]
15
+ */
16
+
17
+ /**
18
+ * @typedef {Object} AutocompleteSuggestions
19
+ * @property {AutocompleteItem[]} items
20
+ * @property {string} prefix
21
+ */
22
+
23
+ /**
24
+ * @typedef {Object} AutocompleteProvider
25
+ * @property {Function} getSuggestions
26
+ * @property {Function} applyCompletion
27
+ */
28
+
29
+ /**
30
+ * 从文本中提取 @ 前缀(用于检测 @ 子Agent 触发)
31
+ * @param {string} textBeforeCursor
32
+ * @returns {string|null} '@prefix' 或 null
33
+ */
34
+ function extractAtPrefixForAgent(textBeforeCursor) {
35
+ // 找最后一个有效的 @ 前缀
36
+ // 有效的 @:前面是行首/空白/非字母数字字符(排除邮箱)
37
+ // 支持多 @ 行:'@foo @bar' → 光标在末尾空格时返回 '@'
38
+ const lastAtIndex = textBeforeCursor.lastIndexOf('@');
39
+ if (lastAtIndex === -1) return null;
40
+
41
+ const prevChar = textBeforeCursor[lastAtIndex - 1];
42
+
43
+ // 邮箱内嵌 @(前一个字符是字母数字)→ 排除
44
+ if (prevChar && /[a-zA-Z0-9]/.test(prevChar)) return null;
45
+
46
+ const afterAt = textBeforeCursor.slice(lastAtIndex + 1);
47
+
48
+ // @ 后紧跟非空白 → 正在输入 agent 名,正常返回前缀
49
+ if (/\S/.test(afterAt)) {
50
+ return textBeforeCursor.slice(lastAtIndex);
51
+ }
52
+
53
+ // @ 后是空白或空 → 独立的 @,触发空前缀列表
54
+ // @ 必须在行首、或前面是空白/非字母数字字符
55
+ if (lastAtIndex === 0 || !prevChar || /\s/.test(prevChar) || /[^\sa-zA-Z0-9]/.test(prevChar)) {
56
+ return '@';
57
+ }
58
+
59
+ return null;
60
+ }
61
+
62
+ class AgentMentionProvider {
63
+ /**
64
+ * @param {Object} framework - Foliko Framework 实例
65
+ * @param {Object} fallbackProvider - 备用的 AutocompleteProvider(如 CombinedAutocompleteProvider)
66
+ */
67
+ constructor(framework, fallbackProvider = null) {
68
+ this.framework = framework;
69
+ this.fallbackProvider = fallbackProvider;
70
+ }
71
+
72
+ /**
73
+ * 获取所有子Agent(排除主Agent)
74
+ * @returns {Array<{name: string, description: string}>}
75
+ */
76
+ getSubAgents() {
77
+ if (!this.framework?._agents) return [];
78
+
79
+ const mainAgent = this.framework._mainAgent;
80
+ return this.framework._agents
81
+ .filter(agent => agent !== mainAgent && agent.name)
82
+ .map(agent => ({
83
+ name: agent.name.replace(/^subagent_/, '').replace(/^session_/, ''),
84
+ description: agent._role || agent.description || agent.name,
85
+ }));
86
+ }
87
+
88
+ /**
89
+ * 判断 @ 前缀是否看起来像子Agent 名称(而非文件路径)
90
+ * @param {string} atPrefix
91
+ * @returns {boolean}
92
+ */
93
+ _looksLikeAgentName(atPrefix) {
94
+ // 如果包含路径分隔符或点号扩展名,认为是文件路径
95
+ if (atPrefix.includes('/') || atPrefix.includes('\\') || atPrefix.includes('.')) {
96
+ return false;
97
+ }
98
+ return true;
99
+ }
100
+
101
+ /**
102
+ * 获取补全建议
103
+ * @returns {Promise<AutocompleteSuggestions|null>}
104
+ */
105
+ async getSuggestions(lines, cursorLine, cursorCol, options) {
106
+ try {
107
+ const currentLine = lines[cursorLine] || '';
108
+ const textBeforeCursor = currentLine.slice(0, cursorCol);
109
+ const atPrefix = extractAtPrefixForAgent(textBeforeCursor);
110
+
111
+ if (!atPrefix || typeof atPrefix !== 'string') {
112
+ // 没有 @ 前缀,委托给 fallback provider
113
+ return this.fallbackProvider?.getSuggestions?.(lines, cursorLine, cursorCol, options) ?? null;
114
+ }
115
+
116
+ const rawPrefix = atPrefix.startsWith('@') ? atPrefix.slice(1) : atPrefix;
117
+
118
+ // 如果看起来像文件路径(包含 / \ .),委托给 fallback
119
+ if (!this._looksLikeAgentName(rawPrefix)) {
120
+ return this.fallbackProvider?.getSuggestions?.(lines, cursorLine, cursorCol, options) ?? null;
121
+ }
122
+
123
+ const subAgents = this.getSubAgents();
124
+ if (!subAgents || subAgents.length === 0) {
125
+ return this.fallbackProvider?.getSuggestions?.(lines, cursorLine, cursorCol, options) ?? null;
126
+ }
127
+
128
+ const items = subAgents.map(agent => ({
129
+ value: agent.name,
130
+ label: agent.name,
131
+ description: agent.description || undefined,
132
+ }));
133
+
134
+ const filtered = fuzzyFilter(items, rawPrefix, (item) => item.label).map(item => ({
135
+ value: item.value,
136
+ label: item.label,
137
+ ...(item.description && { description: item.description }),
138
+ }));
139
+
140
+ if (filtered.length === 0) {
141
+ return null;
142
+ }
143
+
144
+ return {
145
+ items: filtered,
146
+ prefix: atPrefix,
147
+ };
148
+ } catch (err) {
149
+ // 出错时委托给 fallback
150
+ return this.fallbackProvider?.getSuggestions?.(lines, cursorLine, cursorCol, options) ?? null;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * 应用补全
156
+ * @returns {{ lines: string[], cursorLine: number, cursorCol: number }}
157
+ */
158
+ applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
159
+ const currentLine = lines[cursorLine] || '';
160
+ const beforePrefix = currentLine.slice(0, cursorCol - prefix.length);
161
+ const afterCursor = currentLine.slice(cursorCol);
162
+
163
+ const newLine = `${beforePrefix}@${item.value} ${afterCursor}`;
164
+ const newLines = [...lines];
165
+ newLines[cursorLine] = newLine;
166
+
167
+ return {
168
+ lines: newLines,
169
+ cursorLine,
170
+ cursorCol: beforePrefix.length + item.value.length + 2,
171
+ };
172
+ }
173
+ }
174
+
175
+ module.exports = { AgentMentionProvider };