fotric-claw 0.1.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.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +276 -0
  3. package/backend/.env.example +26 -0
  4. package/backend/nest-cli.json +8 -0
  5. package/backend/package-lock.json +13239 -0
  6. package/backend/package.json +82 -0
  7. package/backend/src/agent/agent.module.ts +10 -0
  8. package/backend/src/agent/agent.service.ts +210 -0
  9. package/backend/src/agent/index.ts +4 -0
  10. package/backend/src/agent/llm.factory.ts +20 -0
  11. package/backend/src/agent/tools/fetch.tool.ts +128 -0
  12. package/backend/src/agent/tools/file-read.tool.ts +99 -0
  13. package/backend/src/agent/tools/index.ts +55 -0
  14. package/backend/src/agent/tools/node-repl.tool.ts +82 -0
  15. package/backend/src/agent/tools/rag.tool.ts +192 -0
  16. package/backend/src/agent/tools/shell.tool.ts +65 -0
  17. package/backend/src/app.module.ts +26 -0
  18. package/backend/src/chat/chat.controller.ts +34 -0
  19. package/backend/src/chat/chat.module.ts +12 -0
  20. package/backend/src/chat/chat.service.ts +52 -0
  21. package/backend/src/chat/dto/chat.dto.ts +12 -0
  22. package/backend/src/chat/dto/index.ts +1 -0
  23. package/backend/src/chat/index.ts +4 -0
  24. package/backend/src/config/config.controller.ts +92 -0
  25. package/backend/src/config/config.module.ts +7 -0
  26. package/backend/src/config/constants.ts +56 -0
  27. package/backend/src/config/index.ts +3 -0
  28. package/backend/src/files/files.controller.ts +87 -0
  29. package/backend/src/files/files.module.ts +7 -0
  30. package/backend/src/files/index.ts +2 -0
  31. package/backend/src/main.ts +21 -0
  32. package/backend/src/memory/index.ts +3 -0
  33. package/backend/src/memory/memory.module.ts +10 -0
  34. package/backend/src/memory/memory.service.ts +329 -0
  35. package/backend/src/memory/memory.types.ts +38 -0
  36. package/backend/src/sessions/default.json +7 -0
  37. package/backend/src/sessions/index.ts +2 -0
  38. package/backend/src/sessions/main_session.json +40 -0
  39. package/backend/src/sessions/sessions.controller.ts +25 -0
  40. package/backend/src/sessions/sessions.module.ts +9 -0
  41. package/backend/src/sessions/test.json +16 -0
  42. package/backend/src/skills/browser_search/SKILL.md +81 -0
  43. package/backend/src/skills/get_weather/SKILL.md +72 -0
  44. package/backend/src/skills/index.ts +3 -0
  45. package/backend/src/skills/skill.types.ts +27 -0
  46. package/backend/src/skills/skills.module.ts +8 -0
  47. package/backend/src/skills/skills.service.ts +139 -0
  48. package/backend/src/skills/web_search/SKILL.md +76 -0
  49. package/backend/src/workspace/AGENTS.md +47 -0
  50. package/backend/src/workspace/IDENTITY.md +32 -0
  51. package/backend/src/workspace/MEMORY.md +15 -0
  52. package/backend/src/workspace/SOUL.md +29 -0
  53. package/backend/src/workspace/USER.md +8 -0
  54. package/backend/tsconfig.build.json +4 -0
  55. package/backend/tsconfig.json +26 -0
  56. package/bin/fotric-claw.js +281 -0
  57. package/frontend/next.config.js +14 -0
  58. package/frontend/package-lock.json +5700 -0
  59. package/frontend/package.json +33 -0
  60. package/frontend/postcss.config.js +6 -0
  61. package/frontend/src/app/globals.css +41 -0
  62. package/frontend/src/app/layout.tsx +22 -0
  63. package/frontend/src/app/page.tsx +405 -0
  64. package/frontend/src/lib/api.ts +157 -0
  65. package/frontend/src/lib/utils.ts +3 -0
  66. package/frontend/tailwind.config.js +32 -0
  67. package/frontend/tsconfig.json +26 -0
  68. package/knowledge/README.md +21 -0
  69. package/package.json +49 -0
  70. package/scripts/init-skills.ts +95 -0
  71. package/storage/.gitkeep +5 -0
@@ -0,0 +1,192 @@
1
+ import { Tool } from '@langchain/core/tools';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as path from 'node:path';
4
+ import { FOTRIC_CONFIG } from '../../config';
5
+
6
+ export interface SearchKnowledgeInput {
7
+ query: string;
8
+ topK: number;
9
+ }
10
+
11
+ interface KnowledgeDocument {
12
+ content: string;
13
+ metadata: {
14
+ source: string;
15
+ title?: string;
16
+ };
17
+ }
18
+
19
+ export class HybridRetrievalTool extends Tool {
20
+ name = 'search_knowledge_base';
21
+
22
+ description = `Search the local knowledge base using hybrid retrieval (keyword + semantic search).
23
+ Input should be a JSON object with a "query" field (required) and optional "topK" field.
24
+ Example: {"query": "how to configure API settings", "topK": 5}
25
+ Returns relevant document excerpts from the knowledge base.`;
26
+
27
+ private knowledgeDir: string;
28
+ private storageDir: string;
29
+ private documents: KnowledgeDocument[] = [];
30
+ private initialized = false;
31
+
32
+ constructor(options?: { knowledgeDir?: string; storageDir?: string }) {
33
+ super();
34
+ this.knowledgeDir = path.resolve(options?.knowledgeDir ?? FOTRIC_CONFIG.PATHS.KNOWLEDGE_DIR);
35
+ this.storageDir = path.resolve(options?.storageDir ?? FOTRIC_CONFIG.PATHS.STORAGE_DIR);
36
+ }
37
+
38
+ private async initialize(): Promise<void> {
39
+ if (this.initialized) return;
40
+
41
+ try {
42
+ await fs.mkdir(this.knowledgeDir, { recursive: true });
43
+ await this.loadDocuments();
44
+ this.initialized = true;
45
+ } catch (error) {
46
+ console.error('[FOTRIC-CLAW] Failed to initialize knowledge base:', error);
47
+ }
48
+ }
49
+
50
+ private async loadDocuments(): Promise<void> {
51
+ const extensions = ['.md', '.txt', '.json'];
52
+
53
+ const loadDir = async (dir: string): Promise<void> => {
54
+ try {
55
+ const entries = await fs.readdir(dir, { withFileTypes: true });
56
+
57
+ for (const entry of entries) {
58
+ const fullPath = path.join(dir, entry.name);
59
+
60
+ if (entry.isDirectory()) {
61
+ await loadDir(fullPath);
62
+ } else if (entry.isFile() && extensions.some(ext => entry.name.endsWith(ext))) {
63
+ try {
64
+ const content = await fs.readFile(fullPath, 'utf-8');
65
+ this.documents.push({
66
+ content,
67
+ metadata: {
68
+ source: fullPath,
69
+ title: entry.name,
70
+ },
71
+ });
72
+ } catch {
73
+ console.error(`[FOTRIC-CLAW] Failed to load document: ${fullPath}`);
74
+ }
75
+ }
76
+ }
77
+ } catch {
78
+ console.error(`[FOTRIC-CLAW] Failed to read directory: ${dir}`);
79
+ }
80
+ };
81
+
82
+ await loadDir(this.knowledgeDir);
83
+ }
84
+
85
+ private tokenize(text: string): string[] {
86
+ return text
87
+ .toLowerCase()
88
+ .replace(/[^\w\s\u4e00-\u9fff]/g, ' ')
89
+ .split(/\s+/)
90
+ .filter(token => token.length > 1);
91
+ }
92
+
93
+ private bm25Score(queryTokens: string[], docTokens: string[], avgDocLength: number, k1 = 1.5, b = 0.75): number {
94
+ const docFreq: Record<string, number> = {};
95
+ const docTokenFreq: Record<string, number> = {};
96
+
97
+ for (const token of docTokens) {
98
+ docTokenFreq[token] = (docTokenFreq[token] || 0) + 1;
99
+ }
100
+
101
+ for (const token of queryTokens) {
102
+ if (docTokenFreq[token]) {
103
+ docFreq[token] = 1;
104
+ }
105
+ }
106
+
107
+ const N = this.documents.length;
108
+ const docLength = docTokens.length;
109
+
110
+ let score = 0;
111
+ for (const token of queryTokens) {
112
+ const tf = docTokenFreq[token] || 0;
113
+ const df = docFreq[token] || 0;
114
+ const idf = Math.log((N - df + 0.5) / (df + 0.5) + 1);
115
+ const tfNorm = (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * (docLength / avgDocLength)));
116
+ score += idf * tfNorm;
117
+ }
118
+
119
+ return score;
120
+ }
121
+
122
+ private simpleSemanticScore(queryTokens: string[], docTokens: string[]): number {
123
+ const querySet = new Set(queryTokens);
124
+ const docSet = new Set(docTokens);
125
+
126
+ let intersection = 0;
127
+ for (const token of querySet) {
128
+ if (docSet.has(token)) {
129
+ intersection++;
130
+ }
131
+ }
132
+
133
+ const union = querySet.size + docSet.size - intersection;
134
+ return union > 0 ? intersection / union : 0;
135
+ }
136
+
137
+ async _call(input: string): Promise<string> {
138
+ await this.initialize();
139
+
140
+ let parsedInput: SearchKnowledgeInput;
141
+ try {
142
+ parsedInput = JSON.parse(input);
143
+ } catch {
144
+ parsedInput = { query: input, topK: 5 };
145
+ }
146
+
147
+ const { query, topK = 5 } = parsedInput;
148
+
149
+ if (!query) {
150
+ return 'Error: Query is required.';
151
+ }
152
+
153
+ if (this.documents.length === 0) {
154
+ return 'No documents found in the knowledge base. Please add documents to the knowledge directory.';
155
+ }
156
+
157
+ const queryTokens = this.tokenize(query);
158
+
159
+ const allDocTokens = this.documents.map(doc => this.tokenize(doc.content));
160
+ const totalLength = allDocTokens.reduce((sum, tokens) => sum + tokens.length, 0);
161
+ const avgDocLength = totalLength / this.documents.length;
162
+
163
+ const scored = this.documents.map((doc, index) => {
164
+ const docTokens = allDocTokens[index];
165
+ const bm25 = this.bm25Score(queryTokens, docTokens, avgDocLength);
166
+ const semantic = this.simpleSemanticScore(queryTokens, docTokens);
167
+ const combined = 0.7 * bm25 + 0.3 * semantic;
168
+
169
+ return {
170
+ document: doc,
171
+ score: combined,
172
+ };
173
+ });
174
+
175
+ scored.sort((a, b) => b.score - a.score);
176
+ const topResults = scored.slice(0, topK);
177
+
178
+ if (topResults.length === 0 || topResults[0].score === 0) {
179
+ return 'No relevant documents found for your query.';
180
+ }
181
+
182
+ const results = topResults.map((result, index) => {
183
+ const preview = result.document.content.length > 2000
184
+ ? result.document.content.substring(0, 2000) + '\n...[truncated]'
185
+ : result.document.content;
186
+
187
+ return `--- Document ${index + 1} ---\nSource: ${result.document.metadata.source}\nRelevance Score: ${result.score.toFixed(3)}\n\n${preview}`;
188
+ });
189
+
190
+ return results.join('\n\n');
191
+ }
192
+ }
@@ -0,0 +1,65 @@
1
+ import { Tool } from '@langchain/core/tools';
2
+ import { exec } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import { FOTRIC_CONFIG } from '../../config';
5
+
6
+ const execAsync = promisify(exec);
7
+
8
+ export class ShellTool extends Tool {
9
+ name = 'terminal';
10
+
11
+ description = `Execute shell commands in a sandboxed environment.
12
+ Input should be a valid shell command string.
13
+ The command will be executed with a timeout and safety checks.
14
+ Returns the command output (stdout) or error message.`;
15
+
16
+ private timeout: number;
17
+ private blacklist: string[];
18
+ private cwd: string;
19
+
20
+ constructor(options?: { timeout?: number; cwd?: string }) {
21
+ super();
22
+ this.timeout = options?.timeout ?? FOTRIC_CONFIG.SHELL.TIMEOUT;
23
+ this.cwd = options?.cwd ?? FOTRIC_CONFIG.PATHS.ROOT_DIR;
24
+ this.blacklist = FOTRIC_CONFIG.SHELL.BLACKLIST;
25
+ }
26
+
27
+ private isCommandSafe(command: string): boolean {
28
+ const lowerCommand = command.toLowerCase();
29
+ return !this.blacklist.some(blocked =>
30
+ lowerCommand.includes(blocked.toLowerCase())
31
+ );
32
+ }
33
+
34
+ async _call(command: string): Promise<string> {
35
+ if (!this.isCommandSafe(command)) {
36
+ return `Error: Command blocked for safety reasons. The command contains restricted operations.`;
37
+ }
38
+
39
+ try {
40
+ const { stdout, stderr } = await execAsync(command, {
41
+ cwd: this.cwd,
42
+ timeout: this.timeout,
43
+ maxBuffer: 1024 * 1024 * 10,
44
+ });
45
+
46
+ const output = stdout || stderr || '(no output)';
47
+
48
+ if (output.length > 10000) {
49
+ return output.substring(0, 10000) + '\n...[output truncated]';
50
+ }
51
+
52
+ return output;
53
+ } catch (error) {
54
+ const err = error as { killed?: boolean; signal?: string; message?: string; stderr?: string; stdout?: string };
55
+ if (err.killed && err.signal === 'SIGTERM') {
56
+ return `Error: Command timed out after ${this.timeout}ms`;
57
+ }
58
+ const output = err.stderr || err.stdout || err.message || String(error);
59
+ if (output.length > 10000) {
60
+ return output.substring(0, 10000) + '\n...[output truncated]';
61
+ }
62
+ return `Error: ${output}`;
63
+ }
64
+ }
65
+ }
@@ -0,0 +1,26 @@
1
+ import { Module } from '@nestjs/common';
2
+ import { ConfigModule } from '@nestjs/config';
3
+ import { ChatModule } from './chat';
4
+ import { AgentModule } from './agent';
5
+ import { MemoryModule } from './memory';
6
+ import { SessionsModule } from './sessions';
7
+ import { FilesModule } from './files';
8
+ import { SkillsModule } from './skills';
9
+ import { AppConfigModule } from './config';
10
+
11
+ @Module({
12
+ imports: [
13
+ ConfigModule.forRoot({
14
+ isGlobal: true,
15
+ envFilePath: '.env',
16
+ }),
17
+ SkillsModule,
18
+ MemoryModule,
19
+ AgentModule,
20
+ ChatModule,
21
+ SessionsModule,
22
+ FilesModule,
23
+ AppConfigModule,
24
+ ],
25
+ })
26
+ export class AppModule {}
@@ -0,0 +1,34 @@
1
+ import { Controller, Post, Body, Logger, Sse, MessageEvent } from '@nestjs/common';
2
+ import { Observable } from 'rxjs';
3
+ import { map } from 'rxjs/operators';
4
+ import { ChatService } from './chat.service';
5
+ import { ChatDto } from './dto';
6
+
7
+ @Controller('api')
8
+ export class ChatController {
9
+ private readonly logger = new Logger('FOTRIC-CLAW:ChatController');
10
+
11
+ constructor(private readonly chatService: ChatService) {}
12
+
13
+ @Post('chat')
14
+ async chat(@Body() dto: ChatDto) {
15
+ const sessionId = dto.session_id || 'default';
16
+ this.logger.log(`Chat request from session: ${sessionId}`);
17
+
18
+ const response = await this.chatService.chat(dto);
19
+ return { message: response };
20
+ }
21
+
22
+ @Post('chat/stream')
23
+ @Sse()
24
+ streamChat(@Body() dto: ChatDto): Observable<MessageEvent> {
25
+ const sessionId = dto.session_id || 'default';
26
+ this.logger.log(`Stream chat request from session: ${sessionId}`);
27
+
28
+ return this.chatService.streamChat(dto).pipe(
29
+ map(event => ({
30
+ data: JSON.stringify(event),
31
+ } as MessageEvent)),
32
+ );
33
+ }
34
+ }
@@ -0,0 +1,12 @@
1
+ import { Module } from '@nestjs/common';
2
+ import { ChatController } from './chat.controller';
3
+ import { ChatService } from './chat.service';
4
+ import { AgentModule } from '../agent';
5
+
6
+ @Module({
7
+ imports: [AgentModule],
8
+ controllers: [ChatController],
9
+ providers: [ChatService],
10
+ exports: [ChatService],
11
+ })
12
+ export class ChatModule {}
@@ -0,0 +1,52 @@
1
+ import { Injectable, Logger } from '@nestjs/common';
2
+ import { Observable, Subject } from 'rxjs';
3
+ import { AgentService } from '../agent';
4
+ import { ChatDto, ChatStreamEvent } from './dto';
5
+
6
+ @Injectable()
7
+ export class ChatService {
8
+ private readonly logger = new Logger('FOTRIC-CLAW:Chat');
9
+
10
+ constructor(private readonly agentService: AgentService) {}
11
+
12
+ async chat(dto: ChatDto): Promise<string> {
13
+ const sessionId = dto.session_id || 'default';
14
+ this.logger.log(`Chat request from session: ${sessionId}`);
15
+
16
+ return this.agentService.chat(dto.message, sessionId);
17
+ }
18
+
19
+ streamChat(dto: ChatDto): Observable<ChatStreamEvent> {
20
+ const sessionId = dto.session_id || 'default';
21
+ const subject = new Subject<ChatStreamEvent>();
22
+
23
+ this.logger.log(`Stream chat request from session: ${sessionId}`);
24
+
25
+ (async () => {
26
+ try {
27
+ for await (const chunk of this.agentService.streamChat(dto.message, sessionId)) {
28
+ subject.next({
29
+ type: chunk.type,
30
+ content: chunk.content,
31
+ toolName: chunk.toolName,
32
+ toolArgs: chunk.toolArgs,
33
+ } as ChatStreamEvent);
34
+
35
+ if (chunk.type === 'done') {
36
+ subject.complete();
37
+ }
38
+ }
39
+ } catch (error) {
40
+ const errorMessage = error instanceof Error ? error.message : String(error);
41
+ this.logger.error(`Stream error: ${errorMessage}`);
42
+ subject.next({
43
+ type: 'content',
44
+ content: `Error: ${errorMessage}`,
45
+ });
46
+ subject.complete();
47
+ }
48
+ })();
49
+
50
+ return subject.asObservable();
51
+ }
52
+ }
@@ -0,0 +1,12 @@
1
+ export class ChatDto {
2
+ message: string;
3
+ session_id?: string;
4
+ stream?: boolean;
5
+ }
6
+
7
+ export class ChatStreamEvent {
8
+ type: 'thought' | 'tool_call' | 'content' | 'done';
9
+ content: string;
10
+ toolName?: string;
11
+ toolArgs?: unknown;
12
+ }
@@ -0,0 +1 @@
1
+ export * from './chat.dto';
@@ -0,0 +1,4 @@
1
+ export * from './chat.controller';
2
+ export * from './chat.service';
3
+ export * from './chat.module';
4
+ export * from './dto';
@@ -0,0 +1,92 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import { Controller, Get, Put, Body, Logger } from '@nestjs/common';
4
+
5
+ interface ConfigData {
6
+ apiKey: string;
7
+ apiBaseUrl: string;
8
+ modelName: string;
9
+ }
10
+
11
+ @Controller('api/config')
12
+ export class AppConfigController {
13
+ private readonly logger = new Logger('FOTRIC-CLAW:Config');
14
+ private envPath = path.resolve(process.cwd(), '.env');
15
+
16
+ @Get()
17
+ async getConfig(): Promise<ConfigData> {
18
+ return {
19
+ apiKey: process.env.FOTRIC_API_KEY || '',
20
+ apiBaseUrl: process.env.FOTRIC_API_BASE_URL || 'https://api.openai.com/v1',
21
+ modelName: process.env.FOTRIC_MODEL_NAME || 'gpt-4o',
22
+ };
23
+ }
24
+
25
+ @Put()
26
+ async updateConfig(@Body() config: ConfigData): Promise<{ success: boolean; message: string }> {
27
+ try {
28
+ const envContent = await this.readEnvFile();
29
+ const updatedContent = this.updateEnvContent(envContent, {
30
+ FOTRIC_API_KEY: config.apiKey,
31
+ FOTRIC_API_BASE_URL: config.apiBaseUrl,
32
+ FOTRIC_MODEL_NAME: config.modelName,
33
+ });
34
+
35
+ await fs.writeFile(this.envPath, updatedContent, 'utf-8');
36
+
37
+ process.env.FOTRIC_API_KEY = config.apiKey;
38
+ process.env.FOTRIC_API_BASE_URL = config.apiBaseUrl;
39
+ process.env.FOTRIC_MODEL_NAME = config.modelName;
40
+
41
+ this.logger.log('Configuration updated successfully');
42
+ return { success: true, message: '配置已更新并立即生效' };
43
+ } catch (error) {
44
+ const errorMessage = error instanceof Error ? error.message : String(error);
45
+ this.logger.error(`Failed to update config: ${errorMessage}`);
46
+ return { success: false, message: `配置更新失败: ${errorMessage}` };
47
+ }
48
+ }
49
+
50
+ private maskApiKey(key: string): string {
51
+ if (!key || key.length < 8) return '';
52
+ return key.substring(0, 4) + '****' + key.substring(key.length - 4);
53
+ }
54
+
55
+ private async readEnvFile(): Promise<string> {
56
+ try {
57
+ return await fs.readFile(this.envPath, 'utf-8');
58
+ } catch {
59
+ return '';
60
+ }
61
+ }
62
+
63
+ private updateEnvContent(content: string, updates: Record<string, string>): string {
64
+ const lines = content.split('\n');
65
+ const updatedKeys = new Set<string>();
66
+
67
+ const newLines = lines.map(line => {
68
+ const trimmed = line.trim();
69
+ if (trimmed.startsWith('#') || trimmed === '') {
70
+ return line;
71
+ }
72
+
73
+ const [key, ...valueParts] = trimmed.split('=');
74
+ const envKey = key?.trim();
75
+
76
+ if (envKey && updates[envKey] !== undefined) {
77
+ updatedKeys.add(envKey);
78
+ return `${envKey}=${updates[envKey]}`;
79
+ }
80
+
81
+ return line;
82
+ });
83
+
84
+ for (const [key, value] of Object.entries(updates)) {
85
+ if (!updatedKeys.has(key) && value !== undefined) {
86
+ newLines.push(`${key}=${value}`);
87
+ }
88
+ }
89
+
90
+ return newLines.join('\n');
91
+ }
92
+ }
@@ -0,0 +1,7 @@
1
+ import { Module } from '@nestjs/common';
2
+ import { AppConfigController } from './config.controller';
3
+
4
+ @Module({
5
+ controllers: [AppConfigController],
6
+ })
7
+ export class AppConfigModule {}
@@ -0,0 +1,56 @@
1
+ export const FOTRIC_CONFIG = {
2
+ PREFIX: '[FOTRIC-CLAW]',
3
+ ENV_PREFIX: 'FOTRIC_',
4
+
5
+ get SHELL() {
6
+ return {
7
+ TIMEOUT: parseInt(process.env.FOTRIC_SHELL_TIMEOUT ?? '30000', 10),
8
+ BLACKLIST: [
9
+ 'rm -rf /',
10
+ 'rm -rf /*',
11
+ 'sudo',
12
+ 'chmod -R',
13
+ 'chown -R',
14
+ 'mkfs',
15
+ 'dd if=',
16
+ '> /dev/sd',
17
+ ':(){:|:&};:',
18
+ 'fork bomb',
19
+ ],
20
+ };
21
+ },
22
+
23
+ get REPL() {
24
+ return {
25
+ TIMEOUT: parseInt(process.env.FOTRIC_REPL_TIMEOUT ?? '15000', 10),
26
+ MEMORY_LIMIT: 256 * 1024 * 1024,
27
+ ALLOWED_GLOBALS: ['console', 'Math', 'JSON', 'Array', 'Object', 'String', 'Number', 'Boolean', 'Date', 'RegExp', 'Error', 'Map', 'Set', 'Promise'],
28
+ };
29
+ },
30
+
31
+ get MEMORY() {
32
+ return {
33
+ MAX_CHARS: parseInt(process.env.FOTRIC_MAX_MEMORY_CHARS ?? '20000', 10),
34
+ MAX_TOKENS: parseInt(process.env.FOTRIC_MAX_TOKENS ?? '128000', 10),
35
+ };
36
+ },
37
+
38
+ get PATHS() {
39
+ return {
40
+ ROOT_DIR: process.env.FOTRIC_ROOT_DIR ?? './',
41
+ SKILLS_DIR: process.env.FOTRIC_SKILLS_DIR ?? './src/skills',
42
+ WORKSPACE_DIR: process.env.FOTRIC_WORKSPACE_DIR ?? './src/workspace',
43
+ SESSIONS_DIR: process.env.FOTRIC_SESSIONS_DIR ?? './src/sessions',
44
+ KNOWLEDGE_DIR: process.env.FOTRIC_KNOWLEDGE_DIR ?? '../knowledge',
45
+ STORAGE_DIR: process.env.FOTRIC_STORAGE_DIR ?? '../storage',
46
+ };
47
+ },
48
+
49
+ get LLM() {
50
+ return {
51
+ API_KEY: process.env.FOTRIC_API_KEY ?? '',
52
+ API_BASE_URL: process.env.FOTRIC_API_BASE_URL ?? 'https://api.openai.com/v1',
53
+ MODEL_NAME: process.env.FOTRIC_MODEL_NAME ?? 'gpt-4o',
54
+ };
55
+ },
56
+ };
@@ -0,0 +1,3 @@
1
+ export * from './constants';
2
+ export * from './config.module';
3
+ export * from './config.controller';
@@ -0,0 +1,87 @@
1
+ import { Controller, Get, Post, Body, Query, BadRequestException, Logger } from '@nestjs/common';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as path from 'node:path';
4
+ import { FOTRIC_CONFIG } from '../config';
5
+
6
+ @Controller('api/files')
7
+ export class FilesController {
8
+ private readonly logger = new Logger('FOTRIC-CLAW:FilesController');
9
+ private readonly allowedDirs: string[];
10
+
11
+ constructor() {
12
+ this.allowedDirs = [
13
+ path.resolve(FOTRIC_CONFIG.PATHS.WORKSPACE_DIR),
14
+ path.resolve(FOTRIC_CONFIG.PATHS.SKILLS_DIR),
15
+ ];
16
+ }
17
+
18
+ private isPathAllowed(filePath: string): boolean {
19
+ const resolvedPath = path.resolve(filePath);
20
+ return this.allowedDirs.some(allowedDir =>
21
+ resolvedPath.startsWith(allowedDir)
22
+ );
23
+ }
24
+
25
+ @Get()
26
+ async readFile(@Query('path') filePath: string) {
27
+ if (!filePath) {
28
+ throw new BadRequestException('Path is required');
29
+ }
30
+
31
+ if (!this.isPathAllowed(filePath)) {
32
+ throw new BadRequestException('Access denied. Path must be within allowed directories.');
33
+ }
34
+
35
+ try {
36
+ const content = await fs.readFile(filePath, 'utf-8');
37
+ return { content, path: filePath };
38
+ } catch (error) {
39
+ const errorMessage = error instanceof Error ? error.message : String(error);
40
+ throw new BadRequestException(`Failed to read file: ${errorMessage}`);
41
+ }
42
+ }
43
+
44
+ @Post()
45
+ async writeFile(@Body() body: { path: string; content: string }) {
46
+ const { path: filePath, content } = body;
47
+
48
+ if (!filePath || content === undefined) {
49
+ throw new BadRequestException('Path and content are required');
50
+ }
51
+
52
+ if (!this.isPathAllowed(filePath)) {
53
+ throw new BadRequestException('Access denied. Path must be within allowed directories.');
54
+ }
55
+
56
+ try {
57
+ await fs.writeFile(filePath, content, 'utf-8');
58
+ this.logger.log(`File saved: ${filePath}`);
59
+ return { success: true, path: filePath };
60
+ } catch (error) {
61
+ const errorMessage = error instanceof Error ? error.message : String(error);
62
+ throw new BadRequestException(`Failed to write file: ${errorMessage}`);
63
+ }
64
+ }
65
+
66
+ @Get('list')
67
+ async listFiles(@Query('dir') dir: string) {
68
+ const targetDir = dir || FOTRIC_CONFIG.PATHS.WORKSPACE_DIR;
69
+
70
+ if (!this.isPathAllowed(targetDir)) {
71
+ throw new BadRequestException('Access denied. Directory must be within allowed directories.');
72
+ }
73
+
74
+ try {
75
+ const entries = await fs.readdir(targetDir, { withFileTypes: true });
76
+ const files = entries.map(entry => ({
77
+ name: entry.name,
78
+ isDirectory: entry.isDirectory(),
79
+ path: path.join(targetDir, entry.name),
80
+ }));
81
+ return { files, dir: targetDir };
82
+ } catch (error) {
83
+ const errorMessage = error instanceof Error ? error.message : String(error);
84
+ throw new BadRequestException(`Failed to list directory: ${errorMessage}`);
85
+ }
86
+ }
87
+ }
@@ -0,0 +1,7 @@
1
+ import { Module } from '@nestjs/common';
2
+ import { FilesController } from './files.controller';
3
+
4
+ @Module({
5
+ controllers: [FilesController],
6
+ })
7
+ export class FilesModule {}
@@ -0,0 +1,2 @@
1
+ export * from './files.controller';
2
+ export * from './files.module';