@eldrforge/ai-service 0.1.8 → 0.1.10

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/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { OpenAI } from "openai";
2
- import { safeJsonParse } from "@eldrforge/git-tools";
2
+ import { safeJsonParse, run } from "@eldrforge/git-tools";
3
3
  import fs$1 from "fs";
4
4
  import { spawnSync } from "child_process";
5
5
  import * as path from "path";
@@ -146,6 +146,10 @@ async function createCompletion(messages, options = { model: "gpt-4o-mini" }) {
146
146
  max_completion_tokens: maxCompletionTokens,
147
147
  response_format: options.responseFormat
148
148
  };
149
+ if (options.tools && options.tools.length > 0) {
150
+ apiOptions.tools = options.tools;
151
+ apiOptions.tool_choice = options.toolChoice || "auto";
152
+ }
149
153
  if (options.openaiReasoning && (modelToUse.includes("gpt-5") || modelToUse.includes("o3"))) {
150
154
  apiOptions.reasoning_effort = options.openaiReasoning;
151
155
  }
@@ -178,9 +182,26 @@ async function createCompletion(messages, options = { model: "gpt-4o-mini" }) {
178
182
  await options.storage.writeTemp(debugFile, JSON.stringify(completion, null, 2));
179
183
  logger2.debug("Wrote response debug file to %s", debugFile);
180
184
  }
181
- const response = completion.choices[0]?.message?.content?.trim();
185
+ const message = completion.choices[0]?.message;
186
+ if (!message) {
187
+ throw new OpenAIError("No response message received from OpenAI");
188
+ }
189
+ if (options.tools && options.tools.length > 0) {
190
+ const elapsedTimeFormatted2 = elapsedTime >= 1e3 ? `${(elapsedTime / 1e3).toFixed(1)}s` : `${elapsedTime}ms`;
191
+ logger2.info(" Time: %s", elapsedTimeFormatted2);
192
+ if (completion.usage) {
193
+ logger2.info(
194
+ " Token usage: %s prompt + %s completion = %s total",
195
+ completion.usage.prompt_tokens?.toLocaleString() || "?",
196
+ completion.usage.completion_tokens?.toLocaleString() || "?",
197
+ completion.usage.total_tokens?.toLocaleString() || "?"
198
+ );
199
+ }
200
+ return message;
201
+ }
202
+ const response = message.content?.trim();
182
203
  if (!response) {
183
- throw new OpenAIError("No response received from OpenAI");
204
+ throw new OpenAIError("No response content received from OpenAI");
184
205
  }
185
206
  const responseSize = response.length;
186
207
  const responseSizeKB = (responseSize / 1024).toFixed(2);
@@ -795,18 +816,790 @@ const createReviewPrompt = async ({ overridePaths: _overridePaths, overrides: _o
795
816
  }
796
817
  return recipe(basePath).persona({ path: "personas/you.md" }).instructions({ path: "instructions/review.md" }).overridePaths(_overridePaths ?? []).overrides(_overrides ?? true).content(...contentItems).context(...contextItems).cook();
797
818
  };
819
+ class AgenticExecutor {
820
+ logger;
821
+ toolMetrics = [];
822
+ constructor(logger2) {
823
+ this.logger = logger2;
824
+ }
825
+ /**
826
+ * Run the agentic loop
827
+ */
828
+ async run(config) {
829
+ const {
830
+ messages: initialMessages,
831
+ tools,
832
+ model = "gpt-4o",
833
+ maxIterations = 10,
834
+ debug = false,
835
+ debugRequestFile,
836
+ debugResponseFile,
837
+ storage,
838
+ logger: logger2,
839
+ openaiReasoning = false
840
+ } = config;
841
+ const messages = [...initialMessages];
842
+ let iterations = 0;
843
+ let toolCallsExecuted = 0;
844
+ this.log("Starting agentic loop", { maxIterations, toolCount: tools.count() });
845
+ while (iterations < maxIterations) {
846
+ iterations++;
847
+ this.log(`Iteration ${iterations}/${maxIterations}`);
848
+ const response = await createCompletionWithRetry(
849
+ messages,
850
+ {
851
+ model,
852
+ openaiReasoning: openaiReasoning || void 0,
853
+ debug,
854
+ debugRequestFile: debugRequestFile ? `${debugRequestFile}-iter${iterations}` : void 0,
855
+ debugResponseFile: debugResponseFile ? `${debugResponseFile}-iter${iterations}` : void 0,
856
+ storage,
857
+ logger: logger2,
858
+ tools: tools.toOpenAIFormat()
859
+ }
860
+ );
861
+ const message = typeof response === "string" ? { content: response } : response;
862
+ const toolCalls = message.tool_calls || [];
863
+ if (toolCalls.length === 0) {
864
+ const finalContent = message.content || "";
865
+ this.log("Agent completed without tool calls", { iterations, toolCallsExecuted });
866
+ messages.push({
867
+ role: "assistant",
868
+ content: finalContent
869
+ });
870
+ return {
871
+ finalMessage: finalContent,
872
+ iterations,
873
+ toolCallsExecuted,
874
+ conversationHistory: messages,
875
+ toolMetrics: this.toolMetrics
876
+ };
877
+ }
878
+ messages.push({
879
+ role: "assistant",
880
+ content: message.content || null,
881
+ tool_calls: toolCalls
882
+ });
883
+ this.log(`Executing ${toolCalls.length} tool call(s)`);
884
+ for (const toolCall of toolCalls) {
885
+ const startTime = Date.now();
886
+ const toolName = toolCall.function.name;
887
+ try {
888
+ this.log(`Executing tool: ${toolName}`, toolCall.function.arguments);
889
+ if (this.logger?.info) {
890
+ this.logger.info(`🔧 Running tool: ${toolName}`);
891
+ }
892
+ const args = JSON.parse(toolCall.function.arguments);
893
+ const result = await tools.execute(toolName, args);
894
+ const duration = Date.now() - startTime;
895
+ messages.push({
896
+ role: "tool",
897
+ tool_call_id: toolCall.id,
898
+ content: this.formatToolResult({ id: toolCall.id, name: toolName, result })
899
+ });
900
+ toolCallsExecuted++;
901
+ this.toolMetrics.push({
902
+ name: toolName,
903
+ success: true,
904
+ duration,
905
+ iteration: iterations,
906
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
907
+ });
908
+ this.log(`Tool ${toolName} succeeded in ${duration}ms`);
909
+ if (this.logger?.info) {
910
+ this.logger.info(`✅ Tool ${toolName} completed (${duration}ms)`);
911
+ }
912
+ } catch (error) {
913
+ const duration = Date.now() - startTime;
914
+ const errorMessage = error.message || String(error);
915
+ this.log(`Tool ${toolName} failed: ${errorMessage}`);
916
+ this.toolMetrics.push({
917
+ name: toolName,
918
+ success: false,
919
+ duration,
920
+ error: errorMessage,
921
+ iteration: iterations,
922
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
923
+ });
924
+ if (this.logger?.warn) {
925
+ this.logger.warn(`❌ Tool ${toolName} failed: ${errorMessage}`);
926
+ }
927
+ messages.push({
928
+ role: "tool",
929
+ tool_call_id: toolCall.id,
930
+ content: `Tool execution failed: ${errorMessage}`
931
+ });
932
+ }
933
+ }
934
+ this.log(`Completed tool execution`, { toolCallsExecuted });
935
+ }
936
+ this.log("Max iterations reached, forcing completion", { iterations, toolCallsExecuted });
937
+ messages.push({
938
+ role: "user",
939
+ content: "Please provide your final analysis and commit message based on your investigation. Do not request any more tools."
940
+ });
941
+ const finalResponse = await createCompletionWithRetry(
942
+ messages,
943
+ {
944
+ model,
945
+ openaiReasoning: openaiReasoning || void 0,
946
+ debug,
947
+ storage,
948
+ logger: logger2
949
+ }
950
+ );
951
+ messages.push({
952
+ role: "assistant",
953
+ content: finalResponse
954
+ });
955
+ return {
956
+ finalMessage: finalResponse,
957
+ iterations,
958
+ toolCallsExecuted,
959
+ conversationHistory: messages,
960
+ toolMetrics: this.toolMetrics
961
+ };
962
+ }
963
+ /**
964
+ * Format tool result for inclusion in conversation
965
+ */
966
+ formatToolResult(result) {
967
+ if (typeof result.result === "string") {
968
+ return result.result;
969
+ }
970
+ return JSON.stringify(result.result, null, 2);
971
+ }
972
+ /**
973
+ * Log a message if logger is available
974
+ */
975
+ log(message, data) {
976
+ if (this.logger?.debug) {
977
+ if (data) {
978
+ this.logger.debug(`[AgenticExecutor] ${message}`, data);
979
+ } else {
980
+ this.logger.debug(`[AgenticExecutor] ${message}`);
981
+ }
982
+ }
983
+ }
984
+ }
985
+ async function runAgentic(config) {
986
+ const executor = new AgenticExecutor(config.logger);
987
+ return await executor.run(config);
988
+ }
989
+ class ToolRegistry {
990
+ tools = /* @__PURE__ */ new Map();
991
+ context;
992
+ constructor(context) {
993
+ this.context = context;
994
+ }
995
+ /**
996
+ * Register a tool in the registry
997
+ */
998
+ register(tool) {
999
+ if (this.tools.has(tool.name)) {
1000
+ throw new Error(`Tool with name "${tool.name}" is already registered`);
1001
+ }
1002
+ this.tools.set(tool.name, tool);
1003
+ }
1004
+ /**
1005
+ * Register multiple tools at once
1006
+ */
1007
+ registerAll(tools) {
1008
+ for (const tool of tools) {
1009
+ this.register(tool);
1010
+ }
1011
+ }
1012
+ /**
1013
+ * Get a tool by name
1014
+ */
1015
+ get(name) {
1016
+ return this.tools.get(name);
1017
+ }
1018
+ /**
1019
+ * Get all registered tools
1020
+ */
1021
+ getAll() {
1022
+ return Array.from(this.tools.values());
1023
+ }
1024
+ /**
1025
+ * Check if a tool exists
1026
+ */
1027
+ has(name) {
1028
+ return this.tools.has(name);
1029
+ }
1030
+ /**
1031
+ * Execute a tool by name with given parameters
1032
+ */
1033
+ async execute(name, params) {
1034
+ const tool = this.tools.get(name);
1035
+ if (!tool) {
1036
+ throw new Error(`Tool "${name}" not found in registry`);
1037
+ }
1038
+ try {
1039
+ return await tool.execute(params, this.context);
1040
+ } catch (error) {
1041
+ throw new Error(`Tool "${name}" execution failed: ${error.message}`);
1042
+ }
1043
+ }
1044
+ /**
1045
+ * Convert tools to OpenAI function calling format
1046
+ */
1047
+ toOpenAIFormat() {
1048
+ return this.getAll().map((tool) => ({
1049
+ type: "function",
1050
+ function: {
1051
+ name: tool.name,
1052
+ description: tool.description,
1053
+ parameters: tool.parameters
1054
+ }
1055
+ }));
1056
+ }
1057
+ /**
1058
+ * Get tool definitions (without execute functions) for serialization
1059
+ */
1060
+ getDefinitions() {
1061
+ return this.getAll().map((tool) => ({
1062
+ name: tool.name,
1063
+ description: tool.description,
1064
+ parameters: tool.parameters
1065
+ }));
1066
+ }
1067
+ /**
1068
+ * Clear all registered tools
1069
+ */
1070
+ clear() {
1071
+ this.tools.clear();
1072
+ }
1073
+ /**
1074
+ * Get count of registered tools
1075
+ */
1076
+ count() {
1077
+ return this.tools.size;
1078
+ }
1079
+ }
1080
+ function createToolRegistry(context) {
1081
+ return new ToolRegistry(context);
1082
+ }
1083
+ function createCommitTools() {
1084
+ return [
1085
+ createGetFileHistoryTool(),
1086
+ createGetFileContentTool(),
1087
+ createSearchCodebaseTool(),
1088
+ createGetRelatedTestsTool(),
1089
+ createGetFileDependenciesTool(),
1090
+ createAnalyzeDiffSectionTool(),
1091
+ createGetRecentCommitsTool(),
1092
+ createGroupFilesByConcernTool()
1093
+ ];
1094
+ }
1095
+ function createGetFileHistoryTool() {
1096
+ return {
1097
+ name: "get_file_history",
1098
+ description: "Get git commit history for one or more files to understand their evolution and past changes",
1099
+ parameters: {
1100
+ type: "object",
1101
+ properties: {
1102
+ filePaths: {
1103
+ type: "array",
1104
+ description: "Array of file paths to get history for",
1105
+ items: { type: "string", description: "File path" }
1106
+ },
1107
+ limit: {
1108
+ type: "number",
1109
+ description: "Maximum number of commits to return (default: 10)",
1110
+ default: 10
1111
+ },
1112
+ format: {
1113
+ type: "string",
1114
+ description: 'Output format: "summary" for brief or "detailed" for full messages',
1115
+ enum: ["summary", "detailed"],
1116
+ default: "summary"
1117
+ }
1118
+ },
1119
+ required: ["filePaths"]
1120
+ },
1121
+ execute: async (params, context) => {
1122
+ const { filePaths, limit = 10, format = "summary" } = params;
1123
+ const workingDir = context?.workingDirectory || process.cwd();
1124
+ const formatArg = format === "detailed" ? "--format=%H%n%an (%ae)%n%ad%n%s%n%n%b%n---" : "--format=%h - %s (%an, %ar)";
1125
+ const fileArgs = filePaths.join(" ");
1126
+ const command = `git log ${formatArg} -n ${limit} -- ${fileArgs}`;
1127
+ try {
1128
+ const output = await run(command, { cwd: workingDir });
1129
+ return output.stdout || "No history found for specified files";
1130
+ } catch (error) {
1131
+ throw new Error(`Failed to get file history: ${error.message}`);
1132
+ }
1133
+ }
1134
+ };
1135
+ }
1136
+ function createGetFileContentTool() {
1137
+ return {
1138
+ name: "get_file_content",
1139
+ description: "Get the complete current content of a file to understand context around changes",
1140
+ parameters: {
1141
+ type: "object",
1142
+ properties: {
1143
+ filePath: {
1144
+ type: "string",
1145
+ description: "Path to the file"
1146
+ },
1147
+ includeLineNumbers: {
1148
+ type: "boolean",
1149
+ description: "Include line numbers in output (default: false)",
1150
+ default: false
1151
+ }
1152
+ },
1153
+ required: ["filePath"]
1154
+ },
1155
+ execute: async (params, context) => {
1156
+ const { filePath, includeLineNumbers = false } = params;
1157
+ const storage = context?.storage;
1158
+ if (!storage) {
1159
+ throw new Error("Storage adapter not available in context");
1160
+ }
1161
+ try {
1162
+ const content = await storage.readFile(filePath, "utf-8");
1163
+ if (includeLineNumbers) {
1164
+ const lines = content.split("\n");
1165
+ return lines.map((line, idx) => `${idx + 1}: ${line}`).join("\n");
1166
+ }
1167
+ return content;
1168
+ } catch (error) {
1169
+ throw new Error(`Failed to read file: ${error.message}`);
1170
+ }
1171
+ }
1172
+ };
1173
+ }
1174
+ function createSearchCodebaseTool() {
1175
+ return {
1176
+ name: "search_codebase",
1177
+ description: "Search for code patterns, function names, or text across the codebase using git grep",
1178
+ parameters: {
1179
+ type: "object",
1180
+ properties: {
1181
+ query: {
1182
+ type: "string",
1183
+ description: "Search pattern (can be plain text or regex)"
1184
+ },
1185
+ fileTypes: {
1186
+ type: "array",
1187
+ description: 'Limit search to specific file extensions (e.g., ["ts", "js"])',
1188
+ items: { type: "string", description: "File extension" }
1189
+ },
1190
+ contextLines: {
1191
+ type: "number",
1192
+ description: "Number of context lines to show around matches (default: 2)",
1193
+ default: 2
1194
+ }
1195
+ },
1196
+ required: ["query"]
1197
+ },
1198
+ execute: async (params, context) => {
1199
+ const { query, fileTypes, contextLines = 2 } = params;
1200
+ const workingDir = context?.workingDirectory || process.cwd();
1201
+ let command = `git grep -n -C ${contextLines} "${query}"`;
1202
+ if (fileTypes && fileTypes.length > 0) {
1203
+ const patterns = fileTypes.map((ext) => `'*.${ext}'`).join(" ");
1204
+ command += ` -- ${patterns}`;
1205
+ }
1206
+ try {
1207
+ const output = await run(command, { cwd: workingDir });
1208
+ return output.stdout || "No matches found";
1209
+ } catch (error) {
1210
+ if (error.message.includes("exit code 1")) {
1211
+ return "No matches found";
1212
+ }
1213
+ throw new Error(`Search failed: ${error.message}`);
1214
+ }
1215
+ }
1216
+ };
1217
+ }
1218
+ function createGetRelatedTestsTool() {
1219
+ return {
1220
+ name: "get_related_tests",
1221
+ description: "Find test files related to production files to understand what the code is supposed to do",
1222
+ parameters: {
1223
+ type: "object",
1224
+ properties: {
1225
+ filePaths: {
1226
+ type: "array",
1227
+ description: "Production file paths",
1228
+ items: { type: "string", description: "File path" }
1229
+ }
1230
+ },
1231
+ required: ["filePaths"]
1232
+ },
1233
+ execute: async (params, context) => {
1234
+ const { filePaths } = params;
1235
+ const storage = context?.storage;
1236
+ if (!storage) {
1237
+ throw new Error("Storage adapter not available in context");
1238
+ }
1239
+ const relatedTests = [];
1240
+ for (const filePath of filePaths) {
1241
+ const patterns = [
1242
+ filePath.replace(/\.(ts|js|tsx|jsx)$/, ".test.$1"),
1243
+ filePath.replace(/\.(ts|js|tsx|jsx)$/, ".spec.$1"),
1244
+ filePath.replace("/src/", "/tests/").replace("/lib/", "/tests/"),
1245
+ path.join("tests", filePath),
1246
+ path.join("test", filePath)
1247
+ ];
1248
+ for (const pattern of patterns) {
1249
+ try {
1250
+ await storage.readFile(pattern, "utf-8");
1251
+ relatedTests.push(pattern);
1252
+ } catch {
1253
+ }
1254
+ }
1255
+ }
1256
+ if (relatedTests.length === 0) {
1257
+ return "No related test files found";
1258
+ }
1259
+ return `Found related test files:
1260
+ ${relatedTests.join("\n")}`;
1261
+ }
1262
+ };
1263
+ }
1264
+ function createGetFileDependenciesTool() {
1265
+ return {
1266
+ name: "get_file_dependencies",
1267
+ description: "Find which files import or depend on the changed files to assess change impact",
1268
+ parameters: {
1269
+ type: "object",
1270
+ properties: {
1271
+ filePaths: {
1272
+ type: "array",
1273
+ description: "Files to analyze dependencies for",
1274
+ items: { type: "string", description: "File path" }
1275
+ }
1276
+ },
1277
+ required: ["filePaths"]
1278
+ },
1279
+ execute: async (params, context) => {
1280
+ const { filePaths } = params;
1281
+ const workingDir = context?.workingDirectory || process.cwd();
1282
+ const results = [];
1283
+ for (const filePath of filePaths) {
1284
+ const fileName = path.basename(filePath, path.extname(filePath));
1285
+ const searchPatterns = [
1286
+ `from.*['"].*${fileName}`,
1287
+ `import.*['"].*${fileName}`,
1288
+ `require\\(['"].*${fileName}`
1289
+ ];
1290
+ for (const pattern of searchPatterns) {
1291
+ try {
1292
+ const command = `git grep -l "${pattern}"`;
1293
+ const output = await run(command, { cwd: workingDir });
1294
+ if (output.stdout) {
1295
+ results.push(`Files importing ${filePath}:
1296
+ ${output.stdout}`);
1297
+ break;
1298
+ }
1299
+ } catch {
1300
+ }
1301
+ }
1302
+ }
1303
+ return results.length > 0 ? results.join("\n\n") : "No files found that import the specified files";
1304
+ }
1305
+ };
1306
+ }
1307
+ function createAnalyzeDiffSectionTool() {
1308
+ return {
1309
+ name: "analyze_diff_section",
1310
+ description: "Get expanded context around specific lines in a file to better understand changes",
1311
+ parameters: {
1312
+ type: "object",
1313
+ properties: {
1314
+ filePath: {
1315
+ type: "string",
1316
+ description: "File containing the section to analyze"
1317
+ },
1318
+ startLine: {
1319
+ type: "number",
1320
+ description: "Starting line number"
1321
+ },
1322
+ endLine: {
1323
+ type: "number",
1324
+ description: "Ending line number"
1325
+ },
1326
+ contextLines: {
1327
+ type: "number",
1328
+ description: "Number of additional context lines to show before and after (default: 10)",
1329
+ default: 10
1330
+ }
1331
+ },
1332
+ required: ["filePath", "startLine", "endLine"]
1333
+ },
1334
+ execute: async (params, context) => {
1335
+ const { filePath, startLine, endLine, contextLines = 10 } = params;
1336
+ const storage = context?.storage;
1337
+ if (!storage) {
1338
+ throw new Error("Storage adapter not available in context");
1339
+ }
1340
+ try {
1341
+ const content = await storage.readFile(filePath, "utf-8");
1342
+ const lines = content.split("\n");
1343
+ const actualStart = Math.max(0, startLine - contextLines - 1);
1344
+ const actualEnd = Math.min(lines.length, endLine + contextLines);
1345
+ const section = lines.slice(actualStart, actualEnd).map((line, idx) => `${actualStart + idx + 1}: ${line}`).join("\n");
1346
+ return `Lines ${actualStart + 1}-${actualEnd} from ${filePath}:
1347
+
1348
+ ${section}`;
1349
+ } catch (error) {
1350
+ throw new Error(`Failed to analyze diff section: ${error.message}`);
1351
+ }
1352
+ }
1353
+ };
1354
+ }
1355
+ function createGetRecentCommitsTool() {
1356
+ return {
1357
+ name: "get_recent_commits",
1358
+ description: "Get recent commits that modified the same files to understand recent work in this area",
1359
+ parameters: {
1360
+ type: "object",
1361
+ properties: {
1362
+ filePaths: {
1363
+ type: "array",
1364
+ description: "Files to check for recent commits",
1365
+ items: { type: "string", description: "File path" }
1366
+ },
1367
+ since: {
1368
+ type: "string",
1369
+ description: 'Time period to look back (e.g., "1 week ago", "2 days ago")',
1370
+ default: "1 week ago"
1371
+ },
1372
+ limit: {
1373
+ type: "number",
1374
+ description: "Maximum number of commits to return (default: 5)",
1375
+ default: 5
1376
+ }
1377
+ },
1378
+ required: ["filePaths"]
1379
+ },
1380
+ execute: async (params, context) => {
1381
+ const { filePaths, since = "1 week ago", limit = 5 } = params;
1382
+ const workingDir = context?.workingDirectory || process.cwd();
1383
+ const fileArgs = filePaths.join(" ");
1384
+ const command = `git log --format="%h - %s (%an, %ar)" --since="${since}" -n ${limit} -- ${fileArgs}`;
1385
+ try {
1386
+ const output = await run(command, { cwd: workingDir });
1387
+ return output.stdout || `No commits found in the specified time period (${since})`;
1388
+ } catch (error) {
1389
+ throw new Error(`Failed to get recent commits: ${error.message}`);
1390
+ }
1391
+ }
1392
+ };
1393
+ }
1394
+ function createGroupFilesByConcernTool() {
1395
+ return {
1396
+ name: "group_files_by_concern",
1397
+ description: "Analyze changed files and suggest logical groupings that might represent separate commits",
1398
+ parameters: {
1399
+ type: "object",
1400
+ properties: {
1401
+ filePaths: {
1402
+ type: "array",
1403
+ description: "All changed files to analyze",
1404
+ items: { type: "string", description: "File path" }
1405
+ }
1406
+ },
1407
+ required: ["filePaths"]
1408
+ },
1409
+ execute: async (params) => {
1410
+ const { filePaths } = params;
1411
+ const groups = {};
1412
+ for (const filePath of filePaths) {
1413
+ const dir = path.dirname(filePath);
1414
+ const ext = path.extname(filePath);
1415
+ const basename = path.basename(filePath, ext);
1416
+ let category = "other";
1417
+ if (basename.includes(".test") || basename.includes(".spec") || dir.includes("test")) {
1418
+ category = "tests";
1419
+ } else if (filePath.includes("package.json") || filePath.includes("package-lock.json")) {
1420
+ category = "dependencies";
1421
+ } else if (ext === ".md" || basename === "README") {
1422
+ category = "documentation";
1423
+ } else if (dir.includes("src") || dir.includes("lib")) {
1424
+ category = `source:${dir.split("/").slice(0, 3).join("/")}`;
1425
+ }
1426
+ if (!groups[category]) {
1427
+ groups[category] = [];
1428
+ }
1429
+ groups[category].push(filePath);
1430
+ }
1431
+ const output = Object.entries(groups).map(([category, files]) => {
1432
+ return `${category} (${files.length} files):
1433
+ ${files.map((f) => ` - ${f}`).join("\n")}`;
1434
+ }).join("\n\n");
1435
+ const groupCount = Object.keys(groups).length;
1436
+ const suggestion = groupCount > 1 ? `
1437
+
1438
+ Suggestion: These ${groupCount} groups might be better as separate commits if they represent different concerns.` : "\n\nSuggestion: All files appear to be related and should be in a single commit.";
1439
+ return output + suggestion;
1440
+ }
1441
+ };
1442
+ }
1443
+ async function runAgenticCommit(config) {
1444
+ const {
1445
+ changedFiles,
1446
+ diffContent,
1447
+ userDirection,
1448
+ logContext,
1449
+ model = "gpt-4o",
1450
+ maxIterations = 10,
1451
+ debug = false,
1452
+ debugRequestFile,
1453
+ debugResponseFile,
1454
+ storage,
1455
+ logger: logger2,
1456
+ openaiReasoning
1457
+ } = config;
1458
+ const toolRegistry = createToolRegistry({
1459
+ workingDirectory: process.cwd(),
1460
+ storage,
1461
+ logger: logger2
1462
+ });
1463
+ const tools = createCommitTools();
1464
+ toolRegistry.registerAll(tools);
1465
+ const systemPrompt = buildSystemPrompt();
1466
+ const userMessage = buildUserMessage(changedFiles, diffContent, userDirection, logContext);
1467
+ const messages = [
1468
+ { role: "system", content: systemPrompt },
1469
+ { role: "user", content: userMessage }
1470
+ ];
1471
+ const agenticConfig = {
1472
+ messages,
1473
+ tools: toolRegistry,
1474
+ model,
1475
+ maxIterations,
1476
+ debug,
1477
+ debugRequestFile,
1478
+ debugResponseFile,
1479
+ storage,
1480
+ logger: logger2,
1481
+ openaiReasoning
1482
+ };
1483
+ const result = await runAgentic(agenticConfig);
1484
+ const parsed = parseAgenticResult(result.finalMessage);
1485
+ return {
1486
+ commitMessage: parsed.commitMessage,
1487
+ iterations: result.iterations,
1488
+ toolCallsExecuted: result.toolCallsExecuted,
1489
+ suggestedSplits: parsed.suggestedSplits,
1490
+ conversationHistory: result.conversationHistory,
1491
+ toolMetrics: result.toolMetrics
1492
+ };
1493
+ }
1494
+ function buildSystemPrompt() {
1495
+ return `You are an expert software engineer tasked with generating meaningful commit messages.
1496
+
1497
+ You have access to tools that let you investigate changes in detail:
1498
+ - get_file_history: View commit history for files
1499
+ - get_file_content: Read full file contents
1500
+ - search_codebase: Search for patterns across the codebase
1501
+ - get_related_tests: Find test files related to changes
1502
+ - get_file_dependencies: Understand file dependencies and imports
1503
+ - analyze_diff_section: Get expanded context around specific changes
1504
+ - get_recent_commits: See recent commits to the same files
1505
+ - group_files_by_concern: Suggest logical groupings of changed files
1506
+
1507
+ Your process should be:
1508
+ 1. Analyze the changed files and diff to understand the scope
1509
+ 2. Use tools to investigate specific changes that need more context
1510
+ 3. Identify if changes represent one cohesive commit or multiple logical commits
1511
+ 4. Generate a commit message (or multiple if splits suggested) that accurately describes changes
1512
+
1513
+ Guidelines:
1514
+ - Use tools strategically - don't call every tool on every file
1515
+ - Look at test changes to understand intent
1516
+ - Check recent history to avoid redundant messages
1517
+ - Consider suggesting split commits for unrelated changes
1518
+ - Synthesize findings into clear, informative commit messages
1519
+ - Follow conventional commit format when appropriate (feat:, fix:, refactor:, etc.)
1520
+
1521
+ Output format:
1522
+ When you're ready to provide the final commit message, format it as:
1523
+
1524
+ COMMIT_MESSAGE:
1525
+ [Your generated commit message here]
1526
+
1527
+ If you recommend splitting into multiple commits, also include:
1528
+
1529
+ SUGGESTED_SPLITS:
1530
+ Split 1:
1531
+ Files: [list of files]
1532
+ Rationale: [why these belong together]
1533
+ Message: [commit message for this split]
1534
+
1535
+ Split 2:
1536
+ ...
1537
+
1538
+ If changes should remain as one commit, do not include SUGGESTED_SPLITS section.`;
1539
+ }
1540
+ function buildUserMessage(changedFiles, diffContent, userDirection, logContext) {
1541
+ let message = `I have staged changes that need a commit message.
1542
+
1543
+ Changed files (${changedFiles.length}):
1544
+ ${changedFiles.map((f) => ` - ${f}`).join("\n")}
1545
+
1546
+ Diff:
1547
+ ${diffContent}`;
1548
+ if (userDirection) {
1549
+ message += `
1550
+
1551
+ User direction: ${userDirection}`;
1552
+ }
1553
+ if (logContext) {
1554
+ message += `
1555
+
1556
+ Recent commit history for context:
1557
+ ${logContext}`;
1558
+ }
1559
+ message += `
1560
+
1561
+ Please investigate these changes and generate an appropriate commit message.
1562
+ Use the available tools to gather additional context as needed.
1563
+ If these changes should be split into multiple commits, suggest the groupings.`;
1564
+ return message;
1565
+ }
1566
+ function parseAgenticResult(finalMessage) {
1567
+ const commitMatch = finalMessage.match(/COMMIT_MESSAGE:\s*\n([\s\S]*?)(?=\n\nSUGGESTED_SPLITS:|$)/);
1568
+ const commitMessage = commitMatch ? commitMatch[1].trim() : finalMessage.trim();
1569
+ const suggestedSplits = [];
1570
+ const splitsMatch = finalMessage.match(/SUGGESTED_SPLITS:\s*\n([\s\S]*)/);
1571
+ if (splitsMatch) {
1572
+ const splitsText = splitsMatch[1];
1573
+ const splitRegex = /Split \d+:\s*\nFiles: ([\s\S]*?)\nRationale: ([\s\S]*?)\nMessage: ([\s\S]*?)(?=\n\nSplit \d+:|$)/g;
1574
+ let match;
1575
+ while ((match = splitRegex.exec(splitsText)) !== null) {
1576
+ const filesText = match[1].trim();
1577
+ const files = filesText.split("\n").map((line) => line.trim().replace(/^[-*]\s*/, "")).filter((line) => line.length > 0);
1578
+ suggestedSplits.push({
1579
+ files,
1580
+ rationale: match[2].trim(),
1581
+ message: match[3].trim()
1582
+ });
1583
+ }
1584
+ }
1585
+ return { commitMessage, suggestedSplits };
1586
+ }
798
1587
  export {
1588
+ AgenticExecutor,
799
1589
  OpenAIError,
800
1590
  STANDARD_CHOICES,
801
1591
  SecureTempFile,
1592
+ ToolRegistry,
802
1593
  cleanupTempFile,
803
1594
  createCommitPrompt,
1595
+ createCommitTools,
804
1596
  createCompletion,
805
1597
  createCompletionWithRetry,
806
1598
  createNoOpLogger,
807
1599
  createReleasePrompt,
808
1600
  createReviewPrompt,
809
1601
  createSecureTempFile,
1602
+ createToolRegistry,
810
1603
  editContentInEditor,
811
1604
  getLLMFeedbackInEditor,
812
1605
  getLogger,
@@ -817,6 +1610,8 @@ export {
817
1610
  isRateLimitError,
818
1611
  isTokenLimitError,
819
1612
  requireTTY,
1613
+ runAgentic,
1614
+ runAgenticCommit,
820
1615
  setLogger,
821
1616
  transcribeAudio,
822
1617
  tryLoadWinston