Skip to content

Commit 0b98f41

Browse files
committed
feat(notebook): 添加文件夹级笔记功能
- 支持为文件夹添加笔记,读取文件时自动加载父级文件夹笔记 - 主Agent和子Agent状态隔离,会话压缩后自动重置
1 parent 89eceac commit 0b98f41

7 files changed

Lines changed: 402 additions & 13 deletions

File tree

source/hooks/conversation/useCommandHandler.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
isFileDialogSupported,
1212
} from '../../utils/ui/fileDialog.js';
1313
import {exportMessagesToFile} from '../../utils/session/chatExporter.js';
14+
import {clearReadFolders} from '../../utils/core/folderNotebookPreprocessor.js';
1415

1516
/**
1617
* 执行上下文压缩
@@ -177,6 +178,10 @@ export async function executeContextCompression(sessionId?: string): Promise<{
177178
// 新会话有独立的快照系统,不需要重映射旧会话的快照
178179
// 旧会话的快照保持不变,如果需要回滚到压缩前,可以切换回旧会话
179180

181+
// Clear read folders state after compression
182+
// Folder notebooks will be re-collected when files are read in the new session context
183+
clearReadFolders();
184+
180185
// 同步更新UI消息列表:从会话消息转换为UI Message格式
181186
const newUIMessages: Message[] = [];
182187

source/hooks/conversation/useConversation.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {sessionManager} from '../../utils/session/sessionManager.js';
2222
import {formatTodoContext} from '../../utils/core/todoPreprocessor.js';
2323
import {unifiedHooksExecutor} from '../../utils/execution/unifiedHooksExecutor.js';
2424
import {formatUsefulInfoContext} from '../../utils/core/usefulInfoPreprocessor.js';
25+
import {formatFolderNotebookContext} from '../../utils/core/folderNotebookPreprocessor.js';
2526
import type {Message} from '../../ui/components/chat/MessageList.js';
2627
import {filterToolsBySensitivity} from '../../utils/execution/yoloPermissionChecker.js';
2728
import {
@@ -339,6 +340,15 @@ async function executeWithInternalRetry(
339340
});
340341
}
341342

343+
// Add folder notebook context if available (notes from folders of read files)
344+
const folderNotebookContext = formatFolderNotebookContext();
345+
if (folderNotebookContext) {
346+
conversationMessages.push({
347+
role: 'user',
348+
content: folderNotebookContext,
349+
});
350+
}
351+
342352
// Add history messages from session (includes tool_calls and tool results)
343353
// Load from session to get complete conversation history with tool interactions
344354
// Filter out internal sub-agent messages (marked with subAgentInternal: true)

source/mcp/filesystem.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ import {parseFileSymbols} from './utils/aceCodeSearch/symbol.utils.js';
5454
import type {CodeSymbol} from './types/aceCodeSearch.types.js';
5555
// Notebook utilities for automatic note retrieval
5656
import {queryNotebook} from '../utils/core/notebookManager.js';
57+
// Folder notebook preprocessor for tracking read folders
58+
import {updateReadFolders} from '../utils/core/folderNotebookPreprocessor.js';
5759

5860
const {resolve, dirname, isAbsolute, extname} = path;
5961

@@ -507,6 +509,9 @@ export class FilesystemMCPService {
507509
fileContent += notebookInfo;
508510
}
509511

512+
// Update read folders for folder notebook feature (only for file reads, not directories)
513+
updateReadFolders(file);
514+
510515
multimodalContent.push({
511516
type: 'text',
512517
text: fileContent,
@@ -672,6 +677,9 @@ export class FilesystemMCPService {
672677
partialContent += notebookInfo;
673678
}
674679

680+
// Update read folders for folder notebook feature (only for file reads, not directories)
681+
updateReadFolders(filePath as string);
682+
675683
return {
676684
content: partialContent,
677685
startLine: start,

source/mcp/notebook.ts

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import {Tool, type CallToolResult} from '@modelcontextprotocol/sdk/types.js';
2+
import fs from 'node:fs';
23
import {
34
addNotebook,
45
queryNotebook,
56
updateNotebook,
67
deleteNotebook,
78
getNotebooksByFile,
9+
normalizeFolderPath,
810
} from '../utils/core/notebookManager.js';
911

1012
/**
@@ -14,28 +16,40 @@ import {
1416
export const mcpTools: Tool[] = [
1517
{
1618
name: 'notebook-add',
17-
description: `📝 Record code parts that are fragile and easily broken during iteration.
19+
description: `📝 Record important notes for files or folders to guide future AI interactions.
20+
21+
**Supports both file and folder notebooks:**
22+
- File notebook: Notes for a specific file (e.g., "src/utils/parser.ts")
23+
- Folder notebook: Notes for all files in a folder (e.g., "src/utils/" or "src/utils")
1824
1925
**Core Purpose:** Prevent new features from breaking existing functionality.
2026
21-
**When to record:**
22-
- After fixing bugs that could easily reoccur
23-
- Fragile code that new features might break
24-
- Non-obvious dependencies between components
25-
- Workarounds that shouldn't be "optimized away"
27+
**When to use file notebooks:**
28+
- Fragile code that breaks easily during iteration
29+
- Complex logic that needs explanation
30+
- Edge cases or known limitations
31+
32+
**When to use folder notebooks:**
33+
- Architecture decisions affecting multiple files in a folder
34+
- Coding conventions specific to a module
35+
- Common pitfalls when working in a directory
36+
- Dependencies or requirements for a feature area
2637
2738
**Examples:**
28-
- "⚠️ validateInput() MUST be called first - new features broke this twice"
29-
- "Component X depends on null return - DO NOT change to empty array"
30-
- "setTimeout workaround for race condition - don't remove"
31-
- "Parser expects exact format - adding fields breaks backward compat"`,
39+
- File: "src/api/client.ts" → "⚠️ Rate limiting must be preserved"
40+
- Folder: "src/api/" → "All API calls must handle 401 and retry with refresh token"
41+
42+
**Best Practices:**
43+
- Use folder notebooks for broad guidelines
44+
- Use file notebooks for specific code warnings
45+
- Folder notebooks auto-load when reading any file in that folder`,
3246
inputSchema: {
3347
type: 'object',
3448
properties: {
3549
filePath: {
3650
type: 'string',
3751
description:
38-
'File path (relative or absolute). Example: "src/utils/parser.ts"',
52+
'File or folder path (relative or absolute). For folders, directories are auto-detected and normalized.',
3953
},
4054
note: {
4155
type: 'string',
@@ -182,7 +196,28 @@ export async function executeNotebookTool(
182196
};
183197
}
184198

185-
const entry = addNotebook(filePath, note);
199+
// 检查路径是否存在并判断类型
200+
let normalizedPath = filePath;
201+
try {
202+
const stats = await fs.promises.stat(filePath);
203+
// 如果是目录,规范化路径(确保以 / 结尾)
204+
if (stats.isDirectory()) {
205+
normalizedPath = normalizeFolderPath(filePath);
206+
}
207+
} catch {
208+
// 路径不存在
209+
return {
210+
content: [
211+
{
212+
type: 'text',
213+
text: `Error: Path "${filePath}" does not exist. Notebooks can only be added to existing files or folders.`,
214+
},
215+
],
216+
isError: true,
217+
};
218+
}
219+
220+
const entry = addNotebook(normalizedPath, note);
186221
return {
187222
content: [
188223
{
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
/**
2+
* 文件夹笔记预处理器
3+
* 负责管理已读文件夹状态和格式化文件夹笔记消息
4+
*/
5+
6+
import {
7+
getParentFolderPaths,
8+
readNotebookData,
9+
type FolderNotebook,
10+
} from './notebookManager.js';
11+
12+
/**
13+
* 已读文件夹集合(每个 Agent 实例独立维护)
14+
* 记录当前会话中已经读取过的文件所属的文件夹路径
15+
*/
16+
let readFolders: Set<string> = new Set();
17+
18+
/**
19+
* 更新已读文件夹集合
20+
* @param filePath 读取的文件路径
21+
* @returns 新添加的文件夹列表(用于判断是否有新内容需要展示)
22+
*/
23+
export function updateReadFolders(filePath: string): string[] {
24+
const parentFolders = getParentFolderPaths(filePath);
25+
const newFolders: string[] = [];
26+
27+
for (const folder of parentFolders) {
28+
if (!readFolders.has(folder)) {
29+
readFolders.add(folder);
30+
newFolders.push(folder);
31+
}
32+
}
33+
34+
return newFolders;
35+
}
36+
37+
/**
38+
* 清空已读文件夹集合
39+
* 通常在压缩对话历史后调用
40+
*/
41+
export function clearReadFolders(): void {
42+
readFolders.clear();
43+
}
44+
45+
/**
46+
* 获取当前已读文件夹集合
47+
* @returns 已读文件夹的 Set 副本
48+
*/
49+
export function getReadFolders(): Set<string> {
50+
return new Set(readFolders);
51+
}
52+
53+
/**
54+
* 设置已读文件夹集合
55+
* 用于在子 Agent 执行后恢复主 Agent 的状态
56+
* @param folders 要设置的文件夹集合
57+
*/
58+
export function setReadFolders(folders: Set<string>): void {
59+
readFolders.clear();
60+
for (const folder of folders) {
61+
readFolders.add(folder);
62+
}
63+
}
64+
65+
/**
66+
* 独立的文件夹笔记预处理器实例接口
67+
*/
68+
export interface FolderNotebookPreprocessorInstance {
69+
updateReadFolders: (filePath: string) => string[];
70+
clearReadFolders: () => void;
71+
getReadFolders: () => Set<string>;
72+
formatFolderNotebookContext: (foldersToShow?: string[]) => string;
73+
}
74+
75+
/**
76+
* 创建独立的文件夹笔记预处理器实例
77+
* 用于子 Agent,避免与主 Agent 共享状态
78+
* @returns 独立的预处理器实例
79+
*/
80+
export function createFolderNotebookPreprocessor(): FolderNotebookPreprocessorInstance {
81+
// 独立的已读文件夹集合
82+
const instanceReadFolders = new Set<string>();
83+
84+
return {
85+
/**
86+
* 更新已读文件夹集合
87+
*/
88+
updateReadFolders: (filePath: string): string[] => {
89+
const parentFolders = getParentFolderPaths(filePath);
90+
const newFolders: string[] = [];
91+
92+
for (const folder of parentFolders) {
93+
if (!instanceReadFolders.has(folder)) {
94+
instanceReadFolders.add(folder);
95+
newFolders.push(folder);
96+
}
97+
}
98+
99+
return newFolders;
100+
},
101+
102+
/**
103+
* 清空已读文件夹集合
104+
*/
105+
clearReadFolders: (): void => {
106+
instanceReadFolders.clear();
107+
},
108+
109+
/**
110+
* 获取当前已读文件夹集合
111+
*/
112+
getReadFolders: (): Set<string> => {
113+
return new Set(instanceReadFolders);
114+
},
115+
116+
/**
117+
* 格式化文件夹笔记为 user 消息内容
118+
*/
119+
formatFolderNotebookContext: (foldersToShow?: string[]): string => {
120+
// 收集需要展示的文件夹
121+
const folders = foldersToShow ?? Array.from(instanceReadFolders);
122+
123+
if (folders.length === 0) {
124+
return '';
125+
}
126+
127+
// 收集所有文件夹的笔记
128+
const allNotebooks: FolderNotebook[] = [];
129+
const notebookData = readNotebookData();
130+
131+
for (const folder of folders) {
132+
const entries = notebookData[folder];
133+
if (entries && entries.length > 0) {
134+
allNotebooks.push({
135+
folderPath: folder,
136+
entries: entries.slice(0, 5), // 每个文件夹最新5条
137+
});
138+
}
139+
}
140+
141+
if (allNotebooks.length === 0) {
142+
return '';
143+
}
144+
145+
// 按路径深度排序(从浅到深)
146+
allNotebooks.sort((a, b) => {
147+
const depthA = a.folderPath.split('/').length;
148+
const depthB = b.folderPath.split('/').length;
149+
if (depthA !== depthB) return depthA - depthB;
150+
return a.folderPath.localeCompare(b.folderPath);
151+
});
152+
153+
// 格式化输出
154+
let output = `## 📂 Folder Notebooks (Context from read files)\n\n`;
155+
output += `The following folder notebooks are relevant to files you've read in this session.\n\n`;
156+
157+
for (const notebook of allNotebooks) {
158+
const folderName =
159+
notebook.folderPath === '/'
160+
? '/ (project root)'
161+
: notebook.folderPath;
162+
output += `### ${folderName}\n`;
163+
notebook.entries.forEach((entry, index) => {
164+
output += ` ${index + 1}. [${entry.createdAt}] ${entry.note}\n`;
165+
});
166+
output += '\n';
167+
}
168+
169+
output += `---\n💡 These notes are from folders containing files you've read. They won't repeat.`;
170+
171+
return output;
172+
},
173+
};
174+
}
175+
176+
/**
177+
* 格式化文件夹笔记为 user 消息内容
178+
* @param foldersToShow 需要展示笔记的文件夹列表,如果不传则使用当前 readFolders 集合
179+
* @returns 格式化后的笔记内容,如果没有笔记则返回空字符串
180+
*/
181+
export function formatFolderNotebookContext(foldersToShow?: string[]): string {
182+
// 收集需要展示的文件夹
183+
const folders = foldersToShow ?? Array.from(readFolders);
184+
185+
if (folders.length === 0) {
186+
return '';
187+
}
188+
189+
// 收集所有文件夹的笔记
190+
const allNotebooks: FolderNotebook[] = [];
191+
const notebookData = readNotebookData();
192+
193+
for (const folder of folders) {
194+
const entries = notebookData[folder];
195+
if (entries && entries.length > 0) {
196+
allNotebooks.push({
197+
folderPath: folder,
198+
entries: entries.slice(0, 5), // 每个文件夹最新5条
199+
});
200+
}
201+
}
202+
203+
if (allNotebooks.length === 0) {
204+
return '';
205+
}
206+
207+
// 按路径深度排序(从浅到深)
208+
allNotebooks.sort((a, b) => {
209+
const depthA = a.folderPath.split('/').length;
210+
const depthB = b.folderPath.split('/').length;
211+
if (depthA !== depthB) return depthA - depthB;
212+
return a.folderPath.localeCompare(b.folderPath);
213+
});
214+
215+
// 格式化输出
216+
let output = `## 📂 Folder Notebooks (Context from read files)\n\n`;
217+
output += `The following folder notebooks are relevant to files you've read in this session.\n\n`;
218+
219+
for (const notebook of allNotebooks) {
220+
const folderName =
221+
notebook.folderPath === '/' ? '/ (project root)' : notebook.folderPath;
222+
output += `### ${folderName}\n`;
223+
notebook.entries.forEach((entry, index) => {
224+
output += ` ${index + 1}. [${entry.createdAt}] ${entry.note}\n`;
225+
});
226+
output += '\n';
227+
}
228+
229+
output += `---\n💡 These notes are from folders containing files you've read. They won't repeat.`;
230+
231+
return output;
232+
}

0 commit comments

Comments
 (0)