thatgfsj-code 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/.patches/0001-fix-repl-replace-readline-with-inquirer-input-for-ke.patch +279 -0
  2. package/.patches/0002-fix-repl-stream-AI-output-directly-to-stdout-so-term.patch +564 -0
  3. package/.patches/0003-fix-session-break-the-hallucination-loop-in-assistan.patch +194 -0
  4. package/.patches/0004-chore-release-bump-version-to-0.2.1.patch +24 -0
  5. package/LICENSE +21 -0
  6. package/README.md +100 -0
  7. package/ROADMAP.md +107 -0
  8. package/dist/agent/core.d.ts +56 -0
  9. package/dist/agent/core.d.ts.map +1 -0
  10. package/dist/agent/core.js +142 -0
  11. package/dist/agent/core.js.map +1 -0
  12. package/dist/agent/index.d.ts +8 -0
  13. package/dist/agent/index.d.ts.map +1 -0
  14. package/dist/agent/index.js +8 -0
  15. package/dist/agent/index.js.map +1 -0
  16. package/dist/agent/intent.d.ts +36 -0
  17. package/dist/agent/intent.d.ts.map +1 -0
  18. package/dist/agent/intent.js +146 -0
  19. package/dist/agent/intent.js.map +1 -0
  20. package/dist/agent/streaming.d.ts +50 -0
  21. package/dist/agent/streaming.d.ts.map +1 -0
  22. package/dist/agent/streaming.js +110 -0
  23. package/dist/agent/streaming.js.map +1 -0
  24. package/dist/core/ai-engine.d.ts +59 -0
  25. package/dist/core/ai-engine.d.ts.map +1 -0
  26. package/dist/core/ai-engine.js +378 -0
  27. package/dist/core/ai-engine.js.map +1 -0
  28. package/dist/core/cli.d.ts +21 -0
  29. package/dist/core/cli.d.ts.map +1 -0
  30. package/dist/core/cli.js +143 -0
  31. package/dist/core/cli.js.map +1 -0
  32. package/dist/core/config.d.ts +38 -0
  33. package/dist/core/config.d.ts.map +1 -0
  34. package/dist/core/config.js +129 -0
  35. package/dist/core/config.js.map +1 -0
  36. package/dist/core/context-compactor.d.ts +54 -0
  37. package/dist/core/context-compactor.d.ts.map +1 -0
  38. package/dist/core/context-compactor.js +197 -0
  39. package/dist/core/context-compactor.js.map +1 -0
  40. package/dist/core/hooks.d.ts +75 -0
  41. package/dist/core/hooks.d.ts.map +1 -0
  42. package/dist/core/hooks.js +146 -0
  43. package/dist/core/hooks.js.map +1 -0
  44. package/dist/core/permissions.d.ts +52 -0
  45. package/dist/core/permissions.d.ts.map +1 -0
  46. package/dist/core/permissions.js +237 -0
  47. package/dist/core/permissions.js.map +1 -0
  48. package/dist/core/session.d.ts +54 -0
  49. package/dist/core/session.d.ts.map +1 -0
  50. package/dist/core/session.js +147 -0
  51. package/dist/core/session.js.map +1 -0
  52. package/dist/core/skills.d.ts +60 -0
  53. package/dist/core/skills.d.ts.map +1 -0
  54. package/dist/core/skills.js +175 -0
  55. package/dist/core/skills.js.map +1 -0
  56. package/dist/core/state.d.ts +60 -0
  57. package/dist/core/state.d.ts.map +1 -0
  58. package/dist/core/state.js +106 -0
  59. package/dist/core/state.js.map +1 -0
  60. package/dist/core/subagent.d.ts +68 -0
  61. package/dist/core/subagent.d.ts.map +1 -0
  62. package/dist/core/subagent.js +142 -0
  63. package/dist/core/subagent.js.map +1 -0
  64. package/dist/core/system-prompt.d.ts +40 -0
  65. package/dist/core/system-prompt.d.ts.map +1 -0
  66. package/dist/core/system-prompt.js +138 -0
  67. package/dist/core/system-prompt.js.map +1 -0
  68. package/dist/core/tool-registry.d.ts +53 -0
  69. package/dist/core/tool-registry.d.ts.map +1 -0
  70. package/dist/core/tool-registry.js +138 -0
  71. package/dist/core/tool-registry.js.map +1 -0
  72. package/dist/core/types.d.ts +206 -0
  73. package/dist/core/types.d.ts.map +1 -0
  74. package/dist/core/types.js +127 -0
  75. package/dist/core/types.js.map +1 -0
  76. package/dist/index.d.ts +7 -0
  77. package/dist/index.d.ts.map +1 -0
  78. package/dist/index.js +481 -0
  79. package/dist/index.js.map +1 -0
  80. package/dist/mcp/client.d.ts +83 -0
  81. package/dist/mcp/client.d.ts.map +1 -0
  82. package/dist/mcp/client.js +267 -0
  83. package/dist/mcp/client.js.map +1 -0
  84. package/dist/repl/index.d.ts +8 -0
  85. package/dist/repl/index.d.ts.map +1 -0
  86. package/dist/repl/index.js +8 -0
  87. package/dist/repl/index.js.map +1 -0
  88. package/dist/repl/input.d.ts +61 -0
  89. package/dist/repl/input.d.ts.map +1 -0
  90. package/dist/repl/input.js +127 -0
  91. package/dist/repl/input.js.map +1 -0
  92. package/dist/repl/loop.d.ts +53 -0
  93. package/dist/repl/loop.d.ts.map +1 -0
  94. package/dist/repl/loop.js +241 -0
  95. package/dist/repl/loop.js.map +1 -0
  96. package/dist/repl/output.d.ts +105 -0
  97. package/dist/repl/output.d.ts.map +1 -0
  98. package/dist/repl/output.js +197 -0
  99. package/dist/repl/output.js.map +1 -0
  100. package/dist/repl/welcome.d.ts +68 -0
  101. package/dist/repl/welcome.d.ts.map +1 -0
  102. package/dist/repl/welcome.js +249 -0
  103. package/dist/repl/welcome.js.map +1 -0
  104. package/dist/tools/file.d.ts +46 -0
  105. package/dist/tools/file.d.ts.map +1 -0
  106. package/dist/tools/file.js +100 -0
  107. package/dist/tools/file.js.map +1 -0
  108. package/dist/tools/git.d.ts +52 -0
  109. package/dist/tools/git.d.ts.map +1 -0
  110. package/dist/tools/git.js +112 -0
  111. package/dist/tools/git.js.map +1 -0
  112. package/dist/tools/index.d.ts +22 -0
  113. package/dist/tools/index.d.ts.map +1 -0
  114. package/dist/tools/index.js +43 -0
  115. package/dist/tools/index.js.map +1 -0
  116. package/dist/tools/search.d.ts +36 -0
  117. package/dist/tools/search.d.ts.map +1 -0
  118. package/dist/tools/search.js +223 -0
  119. package/dist/tools/search.js.map +1 -0
  120. package/dist/tools/shell.d.ts +57 -0
  121. package/dist/tools/shell.d.ts.map +1 -0
  122. package/dist/tools/shell.js +153 -0
  123. package/dist/tools/shell.js.map +1 -0
  124. package/dist/utils/diff-preview.d.ts +42 -0
  125. package/dist/utils/diff-preview.d.ts.map +1 -0
  126. package/dist/utils/diff-preview.js +174 -0
  127. package/dist/utils/diff-preview.js.map +1 -0
  128. package/dist/utils/index.d.ts +8 -0
  129. package/dist/utils/index.d.ts.map +1 -0
  130. package/dist/utils/index.js +8 -0
  131. package/dist/utils/index.js.map +1 -0
  132. package/dist/utils/memory.d.ts +81 -0
  133. package/dist/utils/memory.d.ts.map +1 -0
  134. package/dist/utils/memory.js +186 -0
  135. package/dist/utils/memory.js.map +1 -0
  136. package/dist/utils/project-context.d.ts +62 -0
  137. package/dist/utils/project-context.d.ts.map +1 -0
  138. package/dist/utils/project-context.js +197 -0
  139. package/dist/utils/project-context.js.map +1 -0
  140. package/docs/API_KEY_GUIDE.md +236 -0
  141. package/docs/FAQ.md +182 -0
  142. package/install.bat +63 -0
  143. package/install.ps1 +238 -0
  144. package/install.sh +113 -0
  145. package/package.json +36 -0
  146. package/src/agent/core.ts +179 -0
  147. package/src/agent/index.ts +8 -0
  148. package/src/agent/intent.ts +181 -0
  149. package/src/agent/streaming.ts +132 -0
  150. package/src/core/ai-engine.ts +437 -0
  151. package/src/core/cli.ts +171 -0
  152. package/src/core/config.ts +147 -0
  153. package/src/core/context-compactor.ts +245 -0
  154. package/src/core/hooks.ts +196 -0
  155. package/src/core/permissions.ts +308 -0
  156. package/src/core/session.ts +165 -0
  157. package/src/core/skills.ts +208 -0
  158. package/src/core/state.ts +120 -0
  159. package/src/core/subagent.ts +195 -0
  160. package/src/core/system-prompt.ts +163 -0
  161. package/src/core/tool-registry.ts +157 -0
  162. package/src/core/types.ts +280 -0
  163. package/src/index.ts +544 -0
  164. package/src/mcp/client.ts +330 -0
  165. package/src/repl/index.ts +8 -0
  166. package/src/repl/input.ts +139 -0
  167. package/src/repl/loop.ts +280 -0
  168. package/src/repl/output.ts +222 -0
  169. package/src/repl/welcome.ts +296 -0
  170. package/src/tools/file.ts +117 -0
  171. package/src/tools/git.ts +132 -0
  172. package/src/tools/index.ts +48 -0
  173. package/src/tools/search.ts +263 -0
  174. package/src/tools/shell.ts +181 -0
  175. package/src/utils/diff-preview.ts +202 -0
  176. package/src/utils/index.ts +8 -0
  177. package/src/utils/memory.ts +223 -0
  178. package/src/utils/project-context.ts +207 -0
  179. package/tsconfig.json +19 -0
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Config Manager - Handles configuration loading and saving
3
+ * Supports multiple AI providers: MiniMax, SiliconFlow, OpenAI, Anthropic
4
+ */
5
+
6
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
7
+ import { join, dirname } from 'path';
8
+ import { homedir } from 'os';
9
+ import { Config, PROVIDERS, AIConfig } from './types.js';
10
+
11
+ const DEFAULT_CONFIG: Config = {
12
+ model: 'Qwen/Qwen2.5-7B-Instruct',
13
+ apiKey: '',
14
+ temperature: 0.7,
15
+ maxTokens: 4096,
16
+ provider: 'siliconflow',
17
+ baseUrl: 'https://api.siliconflow.cn/v1'
18
+ };
19
+
20
+ export class ConfigManager {
21
+ private configPath: string;
22
+
23
+ constructor() {
24
+ const homeDir = homedir();
25
+ const configDir = join(homeDir, '.thatgfsj');
26
+ this.configPath = join(configDir, 'config.json');
27
+ }
28
+
29
+ /**
30
+ * Load configuration with environment variable support
31
+ */
32
+ static async load(): Promise<Config> {
33
+ const manager = new ConfigManager();
34
+ return manager.loadConfig();
35
+ }
36
+
37
+ /**
38
+ * Load config from file and merge with environment variables
39
+ */
40
+ private loadConfig(): Config {
41
+ let config = { ...DEFAULT_CONFIG };
42
+
43
+ try {
44
+ if (existsSync(this.configPath)) {
45
+ const data = readFileSync(this.configPath, 'utf-8');
46
+ const loaded = JSON.parse(data);
47
+ config = { ...config, ...loaded };
48
+ }
49
+ } catch (error) {
50
+ console.warn('Failed to load config, using defaults:', error);
51
+ }
52
+
53
+ // Resolve provider and get API key from environment
54
+ config = this.resolveProvider(config);
55
+
56
+ return config;
57
+ }
58
+
59
+ /**
60
+ * Resolve provider and API key from config or environment
61
+ */
62
+ private resolveProvider(config: Config): Config {
63
+ const provider = config.provider || 'siliconflow';
64
+ const providerConfig = (provider !== 'custom' && provider !== undefined) ? PROVIDERS[provider] : null;
65
+
66
+ if (!providerConfig) {
67
+ console.warn(`Unknown provider: ${provider}, falling back to siliconflow`);
68
+ return { ...config, provider: 'siliconflow', baseUrl: PROVIDERS.siliconflow.baseUrl };
69
+ }
70
+
71
+ // MODEL environment variable has highest priority
72
+ let model = process.env.MODEL || config.model || providerConfig.defaultModel;
73
+
74
+ // Handle Ollama (local, no API key needed)
75
+ let apiKey = config.apiKey;
76
+ let baseUrl = config.baseUrl || providerConfig.baseUrl;
77
+
78
+ if (provider === 'ollama') {
79
+ // Ollama doesn't need an API key
80
+ apiKey = '';
81
+ // Allow custom base URL via environment
82
+ baseUrl = process.env.OLLAMA_BASE_URL || baseUrl;
83
+ } else {
84
+ // Try to get API key from environment
85
+ for (const envKey of providerConfig.envKeys) {
86
+ if (process.env[envKey]) {
87
+ apiKey = process.env[envKey];
88
+ break;
89
+ }
90
+ }
91
+ }
92
+
93
+ return {
94
+ ...config,
95
+ provider,
96
+ baseUrl,
97
+ model,
98
+ apiKey: apiKey || ''
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Get AIConfig for AIEngine
104
+ */
105
+ static async getAIConfig(): Promise<AIConfig> {
106
+ const config = await ConfigManager.load();
107
+ return {
108
+ model: config.model,
109
+ apiKey: config.apiKey,
110
+ temperature: config.temperature,
111
+ maxTokens: config.maxTokens,
112
+ baseUrl: config.baseUrl,
113
+ provider: config.provider
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Save configuration
119
+ */
120
+ async save(config: Partial<Config>): Promise<void> {
121
+ const current = this.loadConfig();
122
+ const merged = { ...current, ...config };
123
+
124
+ const dir = dirname(this.configPath);
125
+ if (!existsSync(dir)) {
126
+ mkdirSync(dir, { recursive: true });
127
+ }
128
+
129
+ writeFileSync(this.configPath, JSON.stringify(merged, null, 2));
130
+ }
131
+
132
+ /**
133
+ * Get config path
134
+ */
135
+ getPath(): string {
136
+ return this.configPath;
137
+ }
138
+
139
+ /**
140
+ * List available providers
141
+ */
142
+ static listProviders(): string {
143
+ return Object.entries(PROVIDERS)
144
+ .map(([key, val]) => ` ${key}: ${val.name}`)
145
+ .join('\n');
146
+ }
147
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * S05: Context Compactor - 3-tier progressive compression strategy
3
+ *
4
+ * Tier 1: Truncation - Remove oldest messages beyond hard limit
5
+ * Tier 2: Summarize - Replace old message ranges with summary
6
+ * Tier 3: Selective - Keep only recent N turns around tool executions
7
+ *
8
+ * Core insight: Context window is finite. Three-tier strategy enables "infinite" work.
9
+ */
10
+
11
+ import { ChatMessage } from './types.js';
12
+
13
+ export interface CompactorConfig {
14
+ /** Hard limit: messages beyond this get removed first */
15
+ hardLimit?: number;
16
+ /** Soft limit: messages beyond this get summarized */
17
+ softLimit?: number;
18
+ /** How many recent turns to always preserve */
19
+ preserveRecent?: number;
20
+ /** Enable summarization (requires AI API) */
21
+ enableSummarize?: boolean;
22
+ }
23
+
24
+ export interface CompressionResult {
25
+ originalCount: number;
26
+ compactedCount: number;
27
+ removedCount: number;
28
+ method: 'none' | 'truncation' | 'summarize' | 'selective';
29
+ }
30
+
31
+ /**
32
+ * S05: Context Compactor
33
+ */
34
+ export class ContextCompactor {
35
+ private config: CompactorConfig;
36
+ private totalTokensUsed = 0;
37
+
38
+ constructor(config: CompactorConfig = {}) {
39
+ this.config = {
40
+ hardLimit: 50,
41
+ softLimit: 30,
42
+ preserveRecent: 10,
43
+ enableSummarize: false,
44
+ ...config
45
+ };
46
+ }
47
+
48
+ /**
49
+ * S05: Compact messages using 3-tier strategy
50
+ * Called at the end of each agent turn
51
+ */
52
+ compact(messages: ChatMessage[]): { compacted: ChatMessage[]; result: CompressionResult } {
53
+ const count = messages.length;
54
+
55
+ // Tier 1: Within hard limit — no compression
56
+ if (count <= (this.config.hardLimit || 50)) {
57
+ return {
58
+ compacted: messages,
59
+ result: { originalCount: count, compactedCount: count, removedCount: 0, method: 'none' }
60
+ };
61
+ }
62
+
63
+ // Tier 2: Beyond soft limit — summarize middle messages
64
+ if (count > (this.config.softLimit || 30)) {
65
+ return this.summarizeStrategy(messages);
66
+ }
67
+
68
+ // Tier 3: Between soft and hard — selective preservation
69
+ return this.selectiveStrategy(messages);
70
+ }
71
+
72
+ // Tier 1: Simple truncation
73
+ private truncate(messages: ChatMessage[], maxMessages: number): ChatMessage[] {
74
+ const systemMsg = messages.find(m => m.role === 'system');
75
+ const others = messages.filter(m => m.role !== 'system');
76
+ const toRemove = others.length - maxMessages;
77
+
78
+ if (toRemove <= 0 || others.length === 0) {
79
+ return messages;
80
+ }
81
+
82
+ // Remove oldest non-system messages
83
+ const keptOthers = others.slice(toRemove);
84
+ return systemMsg ? [systemMsg, ...keptOthers] : keptOthers;
85
+ }
86
+
87
+ // Tier 2: Summarize middle messages (stub — needs AI for real summarization)
88
+ private summarizeStrategy(messages: ChatMessage[]): { compacted: ChatMessage[]; result: CompressionResult } {
89
+ const preserved = this.config.preserveRecent || 10;
90
+ const systemMsg = messages.find(m => m.role === 'system');
91
+ const others = messages.filter(m => m.role !== 'system');
92
+
93
+ if (others.length <= preserved) {
94
+ return {
95
+ compacted: messages,
96
+ result: { originalCount: messages.length, compactedCount: messages.length, removedCount: 0, method: 'none' }
97
+ };
98
+ }
99
+
100
+ // Keep recent N turns
101
+ const recent = others.slice(-preserved);
102
+
103
+ // Summarize the middle (this is a placeholder — real impl would call AI)
104
+ const summaryMsg: ChatMessage = {
105
+ role: 'system',
106
+ content: '[S05: ' + (others.length - preserved) + ' earlier messages summarized — ' +
107
+ 'conversation had ' + (others.length - preserved) + ' exchanges covering ' +
108
+ this.summarizeTopic(others.slice(0, -preserved)) + ']'
109
+ };
110
+
111
+ const compacted = systemMsg
112
+ ? [systemMsg, summaryMsg, ...recent]
113
+ : [summaryMsg, ...recent];
114
+
115
+ return {
116
+ compacted,
117
+ result: {
118
+ originalCount: messages.length,
119
+ compactedCount: compacted.length,
120
+ removedCount: messages.length - compacted.length,
121
+ method: 'summarize'
122
+ }
123
+ };
124
+ }
125
+
126
+ // Extract simple topic from message content
127
+ private summarizeTopic(msgs: ChatMessage[]): string {
128
+ const texts = msgs
129
+ .filter(m => m.role === 'user')
130
+ .map(m => m.content)
131
+ .slice(0, 3)
132
+ .join(' ')
133
+ .substring(0, 50);
134
+ return texts || 'various topics';
135
+ }
136
+
137
+ // Tier 3: Selective — keep turns around tool executions
138
+ private selectiveStrategy(messages: ChatMessage[]): { compacted: ChatMessage[]; result: CompressionResult } {
139
+ const systemMsg = messages.find(m => m.role === 'system');
140
+ const others = messages.filter(m => m.role !== 'system');
141
+ const max = this.config.softLimit || 30;
142
+
143
+ if (others.length <= max) {
144
+ return {
145
+ compacted: messages,
146
+ result: { originalCount: messages.length, compactedCount: messages.length, removedCount: 0, method: 'none' }
147
+ };
148
+ }
149
+
150
+ // Find indices of tool-related messages
151
+ const toolIndices = new Set<number>();
152
+ others.forEach((m, i) => {
153
+ if (m.tool_calls || m.role === 'tool' || m.role === 'assistant') {
154
+ toolIndices.add(i);
155
+ }
156
+ });
157
+
158
+ // Also add indices around tool messages
159
+ for (const idx of toolIndices) {
160
+ for (let offset = -2; offset <= 2; offset++) {
161
+ toolIndices.add(idx + offset);
162
+ }
163
+ }
164
+
165
+ // Keep system message + messages near tool executions + recent messages
166
+ const recentCount = Math.floor(max * 0.3); // 30% recent
167
+ const toolCount = max - recentCount;
168
+
169
+ const recent = others.slice(-recentCount);
170
+ const toolMsgs: ChatMessage[] = [];
171
+ for (const idx of [...toolIndices].sort((a, b) => a - b)) {
172
+ if (toolMsgs.length >= toolCount) break;
173
+ if (idx >= 0 && idx < others.length - recentCount) {
174
+ toolMsgs.push(others[idx]);
175
+ }
176
+ }
177
+
178
+ const compacted = systemMsg
179
+ ? [systemMsg, ...toolMsgs, ...recent]
180
+ : [...toolMsgs, ...recent];
181
+
182
+ // Deduplicate while preserving order
183
+ const seen = new Set<string>();
184
+ const deduped = compacted.filter(m => {
185
+ const key = m.role + m.content.substring(0, 30);
186
+ if (seen.has(key)) return false;
187
+ seen.add(key);
188
+ return true;
189
+ });
190
+
191
+ return {
192
+ compacted: deduped,
193
+ result: {
194
+ originalCount: messages.length,
195
+ compactedCount: deduped.length,
196
+ removedCount: messages.length - deduped.length,
197
+ method: 'selective'
198
+ }
199
+ };
200
+ }
201
+
202
+ // S05: Check if context is approaching limit
203
+ isNearLimit(messages: ChatMessage[], limit: number = 150000): boolean {
204
+ // Rough estimate: 1 message ~= 200 tokens on average
205
+ const estimatedTokens = messages.length * 200;
206
+ return estimatedTokens > limit * 0.8; // 80% of limit
207
+ }
208
+
209
+ // S05: Get estimated token count
210
+ estimateTokens(messages: ChatMessage[]): number {
211
+ return messages.reduce((sum, m) => {
212
+ // Rough: content chars / 4 tokens
213
+ return sum + Math.ceil(m.content.length / 4) + 10; // +10 for role/overhead
214
+ }, 0);
215
+ }
216
+
217
+ // S05: Auto-compact if needed (called from AIEngine or SessionManager)
218
+ autoCompact(messages: ChatMessage[]): { compacted: ChatMessage[]; result: CompressionResult } {
219
+ if (this.isNearLimit(messages)) {
220
+ return this.compact(messages);
221
+ }
222
+ return {
223
+ compacted: messages,
224
+ result: { originalCount: messages.length, compactedCount: messages.length, removedCount: 0, method: 'none' }
225
+ };
226
+ }
227
+ }
228
+
229
+ // ==================== SessionManager Integration ====================
230
+
231
+ export function integrateWithSessionManager(SessionManagerClass: any) {
232
+ const originalTruncate = SessionManagerClass.prototype.truncate;
233
+
234
+ SessionManagerClass.prototype.truncate = function(maxMessages: number, forceCompact = false) {
235
+ if (forceCompact) {
236
+ const compactor = new ContextCompactor({ hardLimit: maxMessages });
237
+ const { compacted, result } = compactor.autoCompact(this.messages);
238
+ if (result.method !== 'none') {
239
+ this.messages = compacted;
240
+ return result;
241
+ }
242
+ }
243
+ return originalTruncate.call(this, maxMessages);
244
+ };
245
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * S08: Hooks System
3
+ * Inject custom logic at key event points without modifying agent source code
4
+ *
5
+ * Hook points: before/after tool calls, before/after agent loop, onError, etc.
6
+ * Users can register hooks for: audit logging, auto-formatting, security filtering, notifications
7
+ */
8
+
9
+ import { ChatMessage, ToolCall } from './types.js';
10
+
11
+ // ==================== Hook Types ====================
12
+
13
+ export type HookName =
14
+ | 'beforeToolCall'
15
+ | 'afterToolCall'
16
+ | 'beforeAgentLoop'
17
+ | 'afterAgentLoop'
18
+ | 'onError'
19
+ | 'beforeMessage'
20
+ | 'afterMessage'
21
+ | 'onCompact';
22
+
23
+ export interface HookContext {
24
+ toolName?: string;
25
+ toolParams?: Record<string, any>;
26
+ toolResult?: any;
27
+ messages?: ChatMessage[];
28
+ error?: Error;
29
+ iteration?: number;
30
+ [key: string]: any;
31
+ }
32
+
33
+ export type HookFn = (ctx: HookContext) => void | Promise<void>;
34
+
35
+ export interface HookRegistration {
36
+ name: HookName;
37
+ fn: HookFn;
38
+ priority?: number; // Higher = runs first
39
+ once?: boolean; // If true, auto-remove after first run
40
+ }
41
+
42
+ // ==================== Hook Manager ====================
43
+
44
+ export class HookManager {
45
+ private hooks: Map<HookName, HookRegistration[]> = new Map();
46
+
47
+ constructor() {
48
+ // Initialize all hook types
49
+ const allHooks: HookName[] = [
50
+ 'beforeToolCall', 'afterToolCall', 'beforeAgentLoop', 'afterAgentLoop',
51
+ 'onError', 'beforeMessage', 'afterMessage', 'onCompact'
52
+ ];
53
+ for (const h of allHooks) {
54
+ this.hooks.set(h, []);
55
+ }
56
+ }
57
+
58
+ /**
59
+ * S08: Register a hook
60
+ * @param name Hook point name
61
+ * @param fn Function to run
62
+ * @param priority Higher priority runs first
63
+ * @param once If true, auto-removed after first run
64
+ */
65
+ register(name: HookName, fn: HookFn, priority = 0, once = false): void {
66
+ const regs = this.hooks.get(name) || [];
67
+ regs.push({ name, fn, priority, once });
68
+ regs.sort((a, b) => (b.priority || 0) - (a.priority || 0));
69
+ this.hooks.set(name, regs);
70
+ }
71
+
72
+ /**
73
+ * S08: Unregister a hook by function reference
74
+ */
75
+ unregister(name: HookName, fn: HookFn): void {
76
+ const regs = this.hooks.get(name) || [];
77
+ this.hooks.set(name, regs.filter(r => r.fn !== fn));
78
+ }
79
+
80
+ /**
81
+ * S08: Clear all hooks for a given name (or all)
82
+ */
83
+ clear(name?: HookName): void {
84
+ if (name) {
85
+ this.hooks.set(name, []);
86
+ } else {
87
+ for (const [, regs] of this.hooks) {
88
+ regs.length = 0;
89
+ }
90
+ }
91
+ }
92
+
93
+ /**
94
+ * S08: Emit a hook — call all registered functions at that point
95
+ * Returns after all sync hooks complete, awaits async ones
96
+ */
97
+ async emit(name: HookName, ctx: HookContext): Promise<void> {
98
+ const regs = this.hooks.get(name) || [];
99
+
100
+ for (const reg of regs) {
101
+ try {
102
+ await Promise.resolve(reg.fn(ctx));
103
+ } catch (error: any) {
104
+ console.error(`[Hook ${name}] error:`, error.message);
105
+ }
106
+
107
+ // Remove if one-time hook
108
+ if (reg.once) {
109
+ this.unregister(name, reg.fn);
110
+ }
111
+ }
112
+ }
113
+
114
+ /**
115
+ * S08: Check if any hooks are registered for a given name
116
+ */
117
+ hasHooks(name: HookName): boolean {
118
+ return (this.hooks.get(name)?.length || 0) > 0;
119
+ }
120
+
121
+ /**
122
+ * S08: Get hook count
123
+ */
124
+ getHookCount(name?: HookName): number {
125
+ if (name) {
126
+ return this.hooks.get(name)?.length || 0;
127
+ }
128
+ let total = 0;
129
+ for (const regs of this.hooks.values()) {
130
+ total += regs.length;
131
+ }
132
+ return total;
133
+ }
134
+ }
135
+
136
+ // ==================== Built-in Hooks ====================
137
+
138
+ /**
139
+ * S08: Audit logging hook — logs all tool calls
140
+ */
141
+ export function auditLogHook(ctx: HookContext): void {
142
+ if (ctx.toolName) {
143
+ const ts = new Date().toISOString().split('T')[1].split('.')[0];
144
+ const status = ctx.toolResult?.success ? 'OK' : 'FAIL';
145
+ console.log(`[AUDIT ${ts}] ${status} | tool=${ctx.toolName} | params=${JSON.stringify(ctx.toolParams || {})} | result=${ctx.toolResult?.success ? 'OK' : ctx.toolResult?.error}`);
146
+ }
147
+ }
148
+
149
+ /**
150
+ * S08: Prettier auto-format hook — run prettier after file writes
151
+ */
152
+ export async function prettierFormatHook(ctx: HookContext): Promise<void> {
153
+ if (ctx.toolName === 'file' && ctx.toolParams?.action === 'write') {
154
+ const path = ctx.toolParams.path;
155
+ const ext = path.split('.').pop()?.toLowerCase();
156
+
157
+ const formattable = ['js', 'ts', 'jsx', 'tsx', 'json', 'md', 'css', 'html'];
158
+ if (formattable.includes(ext || '')) {
159
+ console.log(`[Hook] Auto-format skipped (prettier not installed) for ${path}`);
160
+ // In real impl: spawn prettier on the file
161
+ }
162
+ }
163
+ }
164
+
165
+ /**
166
+ * S08: Security filter hook — block suspicious patterns
167
+ */
168
+ export function securityFilterHook(ctx: HookContext): { blocked: boolean; reason: string } {
169
+ if (ctx.toolName === 'shell' && ctx.toolParams?.command) {
170
+ const cmd = ctx.toolParams.command;
171
+ const suspicious = [
172
+ /curl\s+.*\|.*sh/i,
173
+ /wget\s+.*\|.*sh/i,
174
+ /base64\s+-d/i,
175
+ /\.\/[^/]+\s+&&.*rm/i
176
+ ];
177
+
178
+ for (const pattern of suspicious) {
179
+ if (pattern.test(cmd)) {
180
+ return { blocked: true, reason: `Suspicious pattern detected: ${pattern}` };
181
+ }
182
+ }
183
+ }
184
+ return { blocked: false, reason: '' };
185
+ }
186
+
187
+ // ==================== Singleton ====================
188
+
189
+ let globalHookManager: HookManager | null = null;
190
+
191
+ export function getHookManager(): HookManager {
192
+ if (!globalHookManager) {
193
+ globalHookManager = new HookManager();
194
+ }
195
+ return globalHookManager;
196
+ }