efarmz-slackbot-data 1.0.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 (131) hide show
  1. package/.clever.json +12 -0
  2. package/.dockerignore +13 -0
  3. package/.env.example +28 -0
  4. package/.github/workflows/deploy-production.yaml +34 -0
  5. package/.prettierrc +6 -0
  6. package/.tasks/F1-bootstrap.md +110 -0
  7. package/.tasks/F2-domain-layer.md +173 -0
  8. package/.tasks/F3-application-layer.md +166 -0
  9. package/.tasks/F4-infrastructure-layer.md +229 -0
  10. package/.tasks/F5-config-main.md +160 -0
  11. package/.tasks/F6-schemas-deployment.md +129 -0
  12. package/CLAUDE.md +163 -0
  13. package/Dockerfile +15 -0
  14. package/PRD.md +119 -0
  15. package/docs/schemas/.gitkeep +0 -0
  16. package/docs/schemas/_guidelines.md +89 -0
  17. package/docs/schemas/efarmz_db.md +759 -0
  18. package/docs/schemas/example.md +16 -0
  19. package/eslint.config.mjs +18 -0
  20. package/package.json +54 -0
  21. package/releaserc.json +15 -0
  22. package/src/.gitkeep +0 -0
  23. package/src/application/agent/.gitkeep +0 -0
  24. package/src/application/agent/AgentContext.test.ts +263 -0
  25. package/src/application/agent/AgentContext.ts +93 -0
  26. package/src/application/agent/AgentLoop.test.ts +275 -0
  27. package/src/application/agent/AgentLoop.ts +101 -0
  28. package/src/application/agent/AgentRunResult.ts +11 -0
  29. package/src/application/agent/LLMMessage.ts +16 -0
  30. package/src/application/agent/tools/RunSqlTool.ts +23 -0
  31. package/src/application/formatting/.gitkeep +0 -0
  32. package/src/application/formatting/CsvRenderer.test.ts +162 -0
  33. package/src/application/formatting/CsvRenderer.ts +34 -0
  34. package/src/application/formatting/MonospaceTableRenderer.test.ts +129 -0
  35. package/src/application/formatting/MonospaceTableRenderer.ts +58 -0
  36. package/src/application/formatting/RenderedResponse.ts +7 -0
  37. package/src/application/formatting/ResponseRenderer.test.ts +159 -0
  38. package/src/application/formatting/ResponseRenderer.ts +39 -0
  39. package/src/application/formatting/ScalarRenderer.test.ts +36 -0
  40. package/src/application/formatting/ScalarRenderer.ts +12 -0
  41. package/src/application/usecases/.gitkeep +0 -0
  42. package/src/application/usecases/AnswerQuestion.test.ts +362 -0
  43. package/src/application/usecases/AnswerQuestion.ts +69 -0
  44. package/src/application/usecases/ParseQuestion.test.ts +39 -0
  45. package/src/application/usecases/ParseQuestion.ts +9 -0
  46. package/src/config/.gitkeep +0 -0
  47. package/src/config/Container.test.ts +35 -0
  48. package/src/config/Container.ts +74 -0
  49. package/src/config/constants.ts +9 -0
  50. package/src/config/env.test.ts +103 -0
  51. package/src/config/env.ts +41 -0
  52. package/src/domain/entities/.gitkeep +0 -0
  53. package/src/domain/entities/Conversation.test.ts +69 -0
  54. package/src/domain/entities/Conversation.ts +26 -0
  55. package/src/domain/entities/ConversationMessage.test.ts +49 -0
  56. package/src/domain/entities/ConversationMessage.ts +18 -0
  57. package/src/domain/entities/index.ts +2 -0
  58. package/src/domain/errors/.gitkeep +0 -0
  59. package/src/domain/errors/AgentLoopExceededError.ts +12 -0
  60. package/src/domain/errors/DomainError.test.ts +106 -0
  61. package/src/domain/errors/DomainError.ts +11 -0
  62. package/src/domain/errors/InvalidSqlError.ts +15 -0
  63. package/src/domain/errors/LLMError.ts +15 -0
  64. package/src/domain/errors/SchemaLoadError.ts +15 -0
  65. package/src/domain/errors/SqlExecutionError.ts +15 -0
  66. package/src/domain/errors/index.ts +15 -0
  67. package/src/domain/ports/.gitkeep +0 -0
  68. package/src/domain/ports/AdminLogger.ts +16 -0
  69. package/src/domain/ports/ConversationRepository.ts +10 -0
  70. package/src/domain/ports/LLMProvider.ts +33 -0
  71. package/src/domain/ports/Logger.ts +8 -0
  72. package/src/domain/ports/SchemaCatalog.ts +5 -0
  73. package/src/domain/ports/SlackMessenger.ts +8 -0
  74. package/src/domain/ports/SqlExecutor.ts +8 -0
  75. package/src/domain/ports/SqlValidator.ts +5 -0
  76. package/src/domain/ports/index.ts +17 -0
  77. package/src/domain/value-objects/.gitkeep +0 -0
  78. package/src/domain/value-objects/LLMProviderName.ts +6 -0
  79. package/src/domain/value-objects/QueryResult.test.ts +51 -0
  80. package/src/domain/value-objects/QueryResult.ts +18 -0
  81. package/src/domain/value-objects/Question.test.ts +59 -0
  82. package/src/domain/value-objects/Question.ts +22 -0
  83. package/src/domain/value-objects/QuestionFlags.test.ts +59 -0
  84. package/src/domain/value-objects/QuestionFlags.ts +18 -0
  85. package/src/domain/value-objects/ResponseRendering.ts +7 -0
  86. package/src/domain/value-objects/SqlQuery.test.ts +40 -0
  87. package/src/domain/value-objects/SqlQuery.ts +12 -0
  88. package/src/domain/value-objects/ThreadId.test.ts +68 -0
  89. package/src/domain/value-objects/ThreadId.ts +27 -0
  90. package/src/domain/value-objects/index.ts +13 -0
  91. package/src/infrastructure/llm/.gitkeep +0 -0
  92. package/src/infrastructure/llm/AnthropicLLMProvider.test.ts +229 -0
  93. package/src/infrastructure/llm/AnthropicLLMProvider.ts +45 -0
  94. package/src/infrastructure/llm/index.ts +4 -0
  95. package/src/infrastructure/llm/mappers/AnthropicMessageMapper.test.ts +173 -0
  96. package/src/infrastructure/llm/mappers/AnthropicMessageMapper.ts +34 -0
  97. package/src/infrastructure/llm/prompts/SystemPromptBuilder.test.ts +41 -0
  98. package/src/infrastructure/llm/prompts/SystemPromptBuilder.ts +31 -0
  99. package/src/infrastructure/llm/prompts/ToolDefinitions.ts +7 -0
  100. package/src/infrastructure/logging/.gitkeep +0 -0
  101. package/src/infrastructure/logging/PinoLogger.test.ts +59 -0
  102. package/src/infrastructure/logging/PinoLogger.ts +28 -0
  103. package/src/infrastructure/logging/index.ts +1 -0
  104. package/src/infrastructure/persistence/.gitkeep +0 -0
  105. package/src/infrastructure/persistence/InMemoryConversationRepository.test.ts +325 -0
  106. package/src/infrastructure/persistence/InMemoryConversationRepository.ts +69 -0
  107. package/src/infrastructure/persistence/PostgresPoolFactory.ts +11 -0
  108. package/src/infrastructure/persistence/PostgresSqlExecutor.test.ts +130 -0
  109. package/src/infrastructure/persistence/PostgresSqlExecutor.ts +34 -0
  110. package/src/infrastructure/persistence/index.ts +3 -0
  111. package/src/infrastructure/schemas/.gitkeep +0 -0
  112. package/src/infrastructure/schemas/FileSystemSchemaCatalog.test.ts +163 -0
  113. package/src/infrastructure/schemas/FileSystemSchemaCatalog.ts +35 -0
  114. package/src/infrastructure/schemas/index.ts +4 -0
  115. package/src/infrastructure/slack/.gitkeep +0 -0
  116. package/src/infrastructure/slack/BoltSlackMessenger.test.ts +59 -0
  117. package/src/infrastructure/slack/BoltSlackMessenger.ts +36 -0
  118. package/src/infrastructure/slack/SlackAdminLogger.test.ts +54 -0
  119. package/src/infrastructure/slack/SlackAdminLogger.ts +27 -0
  120. package/src/infrastructure/slack/SlackApp.ts +9 -0
  121. package/src/infrastructure/slack/handlers/AppMentionHandler.ts +52 -0
  122. package/src/infrastructure/slack/handlers/DirectMessageHandler.ts +65 -0
  123. package/src/infrastructure/slack/index.ts +5 -0
  124. package/src/infrastructure/sql/.gitkeep +0 -0
  125. package/src/infrastructure/sql/RegexSqlValidator.test.ts +242 -0
  126. package/src/infrastructure/sql/RegexSqlValidator.ts +53 -0
  127. package/src/infrastructure/sql/index.ts +1 -0
  128. package/src/main.ts +19 -0
  129. package/tsconfig.json +23 -0
  130. package/vitest.config.ts +15 -0
  131. package/vitest.setup.ts +23 -0
@@ -0,0 +1,275 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import AgentLoop from "../agent/AgentLoop.js";
3
+ import type LLMProvider from "@/domain/ports/LLMProvider.js";
4
+ import type SqlValidator from "@/domain/ports/SqlValidator.js";
5
+ import type SqlExecutor from "@/domain/ports/SqlExecutor.js";
6
+ import type SchemaCatalog from "@/domain/ports/SchemaCatalog.js";
7
+ import type { LLMRequest, LLMResponse } from "@/domain/ports/LLMProvider.js";
8
+ import type SqlQuery from "@/domain/value-objects/SqlQuery.js";
9
+ import type { QueryResult } from "@/domain/value-objects/QueryResult.js";
10
+ import Conversation from "@/domain/entities/Conversation.js";
11
+ import ConversationMessage from "@/domain/entities/ConversationMessage.js";
12
+ import ThreadId from "@/domain/value-objects/ThreadId.js";
13
+ import Question from "@/domain/value-objects/Question.js";
14
+ import AgentLoopExceededError from "@/domain/errors/AgentLoopExceededError.js";
15
+ import InvalidSqlError from "@/domain/errors/InvalidSqlError.js";
16
+
17
+ class FakeLLMProvider implements LLMProvider {
18
+ private responses: LLMResponse[] = [];
19
+ private callCount = 0;
20
+
21
+ addResponse(response: LLMResponse): this {
22
+ this.responses.push(response);
23
+ return this;
24
+ }
25
+
26
+ async generate(_request: LLMRequest): Promise<LLMResponse> {
27
+ const response = this.responses[this.callCount];
28
+ if (!response) {
29
+ throw new Error(`No more responses configured for FakeLLMProvider (called ${this.callCount + 1} times)`);
30
+ }
31
+ this.callCount += 1;
32
+ return response;
33
+ }
34
+
35
+ getCallCount(): number {
36
+ return this.callCount;
37
+ }
38
+ }
39
+
40
+ class FakeSqlValidator implements SqlValidator {
41
+ private shouldFail = false;
42
+
43
+ failValidation(): this {
44
+ this.shouldFail = true;
45
+ return this;
46
+ }
47
+
48
+ validate(raw: string): string {
49
+ if (this.shouldFail) {
50
+ throw new InvalidSqlError(raw, `Validation failed`);
51
+ }
52
+ return raw;
53
+ }
54
+ }
55
+
56
+ class FakeSqlExecutor implements SqlExecutor {
57
+ private shouldFail = false;
58
+ private failureMessage = `Execution error`;
59
+
60
+ failExecution(message: string): this {
61
+ this.shouldFail = true;
62
+ this.failureMessage = message;
63
+ return this;
64
+ }
65
+
66
+ async execute(_query: SqlQuery): Promise<QueryResult> {
67
+ if (this.shouldFail) {
68
+ throw new Error(this.failureMessage);
69
+ }
70
+ return {
71
+ columns: [`result`],
72
+ rows: [{ result: `test_value` }],
73
+ rowCount: 1,
74
+ };
75
+ }
76
+ }
77
+
78
+ class FakeSchemaCatalog implements SchemaCatalog {
79
+ getDocumentation(): string {
80
+ return `Schema: test_table\n- id: integer\n- name: text`;
81
+ }
82
+ }
83
+
84
+ describe(`AgentLoop`, () => {
85
+ it(`should return finalText on immediate end_turn`, async () => {
86
+ const llm = new FakeLLMProvider().addResponse({
87
+ content: `The answer is 42`,
88
+ toolUses: [],
89
+ stopReason: `end_turn`,
90
+ inputTokens: 100,
91
+ outputTokens: 50,
92
+ });
93
+
94
+ const agent = new AgentLoop(llm, new FakeSqlValidator(), new FakeSqlExecutor(), new FakeSchemaCatalog(), 5);
95
+
96
+ const result = await agent.run({
97
+ conversation: null,
98
+ question: Question.create(`What is the answer?`),
99
+ });
100
+
101
+ expect(result.finalText).toBe(`The answer is 42`);
102
+ expect(result.executedSqls).toHaveLength(0);
103
+ expect(result.queryResult).toBeNull();
104
+ expect(result.inputTokens).toBe(100);
105
+ expect(result.outputTokens).toBe(50);
106
+ expect(llm.getCallCount()).toBe(1);
107
+ });
108
+
109
+ it(`should execute SQL and continue to end_turn`, async () => {
110
+ const llm = new FakeLLMProvider()
111
+ .addResponse({
112
+ content: ``,
113
+ toolUses: [
114
+ {
115
+ id: `tool_1`,
116
+ name: `run_sql`,
117
+ input: { sql: `SELECT 1 as num` },
118
+ },
119
+ ],
120
+ stopReason: `tool_use`,
121
+ inputTokens: 100,
122
+ outputTokens: 50,
123
+ })
124
+ .addResponse({
125
+ content: `The result is 1`,
126
+ toolUses: [],
127
+ stopReason: `end_turn`,
128
+ inputTokens: 150,
129
+ outputTokens: 60,
130
+ });
131
+
132
+ const agent = new AgentLoop(llm, new FakeSqlValidator(), new FakeSqlExecutor(), new FakeSchemaCatalog(), 5);
133
+
134
+ const result = await agent.run({
135
+ conversation: null,
136
+ question: Question.create(`Count something`),
137
+ });
138
+
139
+ expect(result.finalText).toBe(`The result is 1`);
140
+ expect(result.executedSqls).toHaveLength(1);
141
+ expect(result.executedSqls[0]).toBe(`SELECT 1 as num`);
142
+ expect(result.queryResult).toEqual({
143
+ columns: [`result`],
144
+ rows: [{ result: `test_value` }],
145
+ rowCount: 1,
146
+ });
147
+ expect(result.inputTokens).toBe(250);
148
+ expect(result.outputTokens).toBe(110);
149
+ });
150
+
151
+ it(`should handle SQL validation errors without throwing`, async () => {
152
+ const validator = new FakeSqlValidator().failValidation();
153
+ const executor = new FakeSqlExecutor();
154
+
155
+ const llm = new FakeLLMProvider()
156
+ .addResponse({
157
+ content: ``,
158
+ toolUses: [
159
+ {
160
+ id: `tool_1`,
161
+ name: `run_sql`,
162
+ input: { sql: `DROP TABLE users` },
163
+ },
164
+ ],
165
+ stopReason: `tool_use`,
166
+ inputTokens: 100,
167
+ outputTokens: 50,
168
+ })
169
+ .addResponse({
170
+ content: `The query was invalid`,
171
+ toolUses: [],
172
+ stopReason: `end_turn`,
173
+ inputTokens: 150,
174
+ outputTokens: 60,
175
+ });
176
+
177
+ const agent = new AgentLoop(llm, validator, executor, new FakeSchemaCatalog(), 5);
178
+
179
+ const result = await agent.run({
180
+ conversation: null,
181
+ question: Question.create(`Do something bad`),
182
+ });
183
+
184
+ expect(result.finalText).toBe(`The query was invalid`);
185
+ expect(result.executedSqls).toHaveLength(0);
186
+ expect(llm.getCallCount()).toBe(2);
187
+ });
188
+
189
+ it(`should handle SQL execution errors without throwing`, async () => {
190
+ const executor = new FakeSqlExecutor().failExecution(`Connection timeout`);
191
+
192
+ const llm = new FakeLLMProvider()
193
+ .addResponse({
194
+ content: ``,
195
+ toolUses: [
196
+ {
197
+ id: `tool_1`,
198
+ name: `run_sql`,
199
+ input: { sql: `SELECT * FROM nonexistent` },
200
+ },
201
+ ],
202
+ stopReason: `tool_use`,
203
+ inputTokens: 100,
204
+ outputTokens: 50,
205
+ })
206
+ .addResponse({
207
+ content: `Query failed, trying another approach`,
208
+ toolUses: [],
209
+ stopReason: `end_turn`,
210
+ inputTokens: 150,
211
+ outputTokens: 60,
212
+ });
213
+
214
+ const agent = new AgentLoop(llm, new FakeSqlValidator(), executor, new FakeSchemaCatalog(), 5);
215
+
216
+ const result = await agent.run({
217
+ conversation: null,
218
+ question: Question.create(`Get data`),
219
+ });
220
+
221
+ expect(result.finalText).toBe(`Query failed, trying another approach`);
222
+ expect(result.executedSqls).toHaveLength(0);
223
+ expect(result.queryResult).toBeNull();
224
+ });
225
+
226
+ it(`should throw AgentLoopExceededError when max turns reached`, async () => {
227
+ const llm = new FakeLLMProvider().addResponse({
228
+ content: ``,
229
+ toolUses: [
230
+ {
231
+ id: `tool_1`,
232
+ name: `run_sql`,
233
+ input: { sql: `SELECT 1` },
234
+ },
235
+ ],
236
+ stopReason: `tool_use`,
237
+ inputTokens: 100,
238
+ outputTokens: 50,
239
+ });
240
+
241
+ const agent = new AgentLoop(llm, new FakeSqlValidator(), new FakeSqlExecutor(), new FakeSchemaCatalog(), 1);
242
+
243
+ await expect(
244
+ agent.run({
245
+ conversation: null,
246
+ question: Question.create(`Query`),
247
+ }),
248
+ ).rejects.toThrow(AgentLoopExceededError);
249
+ });
250
+
251
+ it(`should restore conversation history on run`, async () => {
252
+ const llm = new FakeLLMProvider().addResponse({
253
+ content: `Continuing from history`,
254
+ toolUses: [],
255
+ stopReason: `end_turn`,
256
+ inputTokens: 100,
257
+ outputTokens: 50,
258
+ });
259
+
260
+ const threadId = ThreadId.create(`C123456789`, `1234567890.123456`);
261
+ let conversation = Conversation.create(threadId);
262
+ conversation = conversation.append(ConversationMessage.create(`user`, `First question`));
263
+ conversation = conversation.append(ConversationMessage.create(`assistant`, `First answer`));
264
+
265
+ const agent = new AgentLoop(llm, new FakeSqlValidator(), new FakeSqlExecutor(), new FakeSchemaCatalog(), 5);
266
+
267
+ const result = await agent.run({
268
+ conversation,
269
+ question: Question.create(`Follow-up question`),
270
+ });
271
+
272
+ expect(result.finalText).toBe(`Continuing from history`);
273
+ expect(llm.getCallCount()).toBe(1);
274
+ });
275
+ });
@@ -0,0 +1,101 @@
1
+ import type LLMProvider from "@/domain/ports/LLMProvider.js";
2
+ import type SqlValidator from "@/domain/ports/SqlValidator.js";
3
+ import type SqlExecutor from "@/domain/ports/SqlExecutor.js";
4
+ import type SchemaCatalog from "@/domain/ports/SchemaCatalog.js";
5
+ import type Conversation from "@/domain/entities/Conversation.js";
6
+ import type Question from "@/domain/value-objects/Question.js";
7
+ import AgentLoopExceededError from "@/domain/errors/AgentLoopExceededError.js";
8
+ import SqlQuery from "@/domain/value-objects/SqlQuery.js";
9
+ import AgentContext from "./AgentContext.js";
10
+ import type AgentRunResult from "./AgentRunResult.js";
11
+ import type LLMMessage from "./LLMMessage.js";
12
+ import RunSqlTool from "./tools/RunSqlTool.js";
13
+ import type { RunSqlInput } from "./tools/RunSqlTool.js";
14
+
15
+ class AgentLoop {
16
+ constructor(
17
+ private readonly llmProvider: LLMProvider,
18
+ private readonly sqlValidator: SqlValidator,
19
+ private readonly sqlExecutor: SqlExecutor,
20
+ private readonly schemaCatalog: SchemaCatalog,
21
+ private readonly maxTurns: number,
22
+ ) {}
23
+
24
+ async run(params: {
25
+ conversation: Conversation | null;
26
+ question: Question;
27
+ }): Promise<AgentRunResult> {
28
+ const context = AgentContext.init(params.conversation, params.question);
29
+ const systemPrompt = this.buildSystemPrompt();
30
+
31
+ for (let turn = 0; turn < this.maxTurns; turn++) {
32
+ const response = await this.llmProvider.generate({
33
+ systemPrompt,
34
+ messages: this.transformMessages(context.messages),
35
+ tools: [RunSqlTool.definition],
36
+ });
37
+
38
+ context.appendAssistantMessage(response.content, response.inputTokens, response.outputTokens);
39
+
40
+ if (response.stopReason === `end_turn`) {
41
+ return {
42
+ finalText: response.content,
43
+ executedSqls: context.executedSqls,
44
+ queryResult: context.lastQueryResult,
45
+ inputTokens: context.totalInputTokens,
46
+ outputTokens: context.totalOutputTokens,
47
+ };
48
+ }
49
+
50
+ for (const toolUse of response.toolUses) {
51
+ if (toolUse.name === `run_sql`) {
52
+ await this.handleRunSql(context, toolUse.id, toolUse.input as RunSqlInput);
53
+ }
54
+ }
55
+ }
56
+
57
+ throw new AgentLoopExceededError(this.maxTurns);
58
+ }
59
+
60
+ private async handleRunSql(
61
+ context: AgentContext,
62
+ toolUseId: string,
63
+ input: RunSqlInput,
64
+ ): Promise<void> {
65
+ try {
66
+ const query = SqlQuery.create(input.sql, this.sqlValidator);
67
+ const result = await this.sqlExecutor.execute(query);
68
+ context.recordSql(input.sql, result);
69
+ context.appendToolResult(toolUseId, JSON.stringify(result), false);
70
+ } catch (error) {
71
+ const errorMessage = error instanceof Error ? error.message : String(error);
72
+ context.appendToolResult(toolUseId, errorMessage, true);
73
+ }
74
+ }
75
+
76
+ private transformMessages(
77
+ messages: readonly LLMMessage[],
78
+ ): Array<{ role: "user" | "assistant"; content: string }> {
79
+ return messages.map((msg) => ({
80
+ role: msg.role,
81
+ content: typeof msg.content === `string` ? msg.content : JSON.stringify(msg.content),
82
+ }));
83
+ }
84
+
85
+ private buildSystemPrompt(): string {
86
+ const schemas = this.schemaCatalog.getDocumentation();
87
+ return `You are an SQL expert assistant for efarmz analytics.
88
+
89
+ ${schemas}
90
+
91
+ Instructions:
92
+ - You can execute read-only SQL SELECT queries to answer user questions
93
+ - You MUST always think step-by-step before writing SQL
94
+ - Use the available schema information to construct valid queries
95
+ - Ensure queries are efficient and respect the limits (max 1000 rows, 30s timeout)
96
+ - If a query fails, analyze the error and try a different approach
97
+ - Provide clear explanations of what the data shows`;
98
+ }
99
+ }
100
+
101
+ export default AgentLoop;
@@ -0,0 +1,11 @@
1
+ import QueryResult from "@/domain/value-objects/QueryResult";
2
+
3
+ type AgentRunResult = {
4
+ readonly finalText: string;
5
+ readonly executedSqls: readonly string[];
6
+ readonly queryResult: QueryResult | null;
7
+ readonly inputTokens: number;
8
+ readonly outputTokens: number;
9
+ };
10
+
11
+ export default AgentRunResult;
@@ -0,0 +1,16 @@
1
+ type LLMMessageRole = `user` | `assistant`;
2
+
3
+ type LLMToolResultContent = {
4
+ readonly type: `tool_result`;
5
+ readonly toolUseId: string;
6
+ readonly content: string;
7
+ readonly isError: boolean;
8
+ };
9
+
10
+ type LLMMessage = {
11
+ readonly role: LLMMessageRole;
12
+ readonly content: string | LLMToolResultContent;
13
+ };
14
+
15
+ export default LLMMessage;
16
+ export type { LLMMessageRole, LLMToolResultContent };
@@ -0,0 +1,23 @@
1
+ const RunSqlTool = {
2
+ definition: {
3
+ name: `run_sql`,
4
+ description: `Execute a read-only SQL SELECT query on the efarmz PostgreSQL databases. The query must start with SELECT or WITH and cannot contain INSERT, UPDATE, DELETE, DROP, or other mutation keywords. Results are limited to 1000 rows.`,
5
+ inputSchema: {
6
+ type: `object`,
7
+ properties: {
8
+ sql: {
9
+ type: `string`,
10
+ description: `The SQL SELECT query to execute. Must be a valid PostgreSQL query.`,
11
+ },
12
+ },
13
+ required: [`sql`],
14
+ },
15
+ },
16
+ } as const;
17
+
18
+ type RunSqlInput = {
19
+ sql: string;
20
+ };
21
+
22
+ export default RunSqlTool;
23
+ export type { RunSqlInput };
File without changes
@@ -0,0 +1,162 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import CsvRenderer from "../formatting/CsvRenderer";
3
+ import QueryResult from "@/domain/value-objects/QueryResult";
4
+
5
+ describe(`CsvRenderer`, () => {
6
+ it(`renders simple data as CSV`, () => {
7
+ const result = QueryResult.create(
8
+ [`name`, `age`],
9
+ [
10
+ { name: `Alice`, age: 30 },
11
+ { name: `Bob`, age: 25 },
12
+ ]
13
+ );
14
+
15
+ const buffer = CsvRenderer.render(result);
16
+ const csv = buffer.toString(`utf-8`);
17
+
18
+ expect(csv).toBe(`name,age\nAlice,30\nBob,25`);
19
+ });
20
+
21
+ it(`escapes values containing comma`, () => {
22
+ const result = QueryResult.create(
23
+ [`name`, `city`],
24
+ [{ name: `Smith, John`, city: `New York` }]
25
+ );
26
+
27
+ const buffer = CsvRenderer.render(result);
28
+ const csv = buffer.toString(`utf-8`);
29
+
30
+ expect(csv).toBe(`name,city\n"Smith, John",New York`);
31
+ });
32
+
33
+ it(`escapes double quotes by doubling them`, () => {
34
+ const result = QueryResult.create(
35
+ [`text`],
36
+ [{ text: `He said "hello"` }]
37
+ );
38
+
39
+ const buffer = CsvRenderer.render(result);
40
+ const csv = buffer.toString(`utf-8`);
41
+
42
+ expect(csv).toBe(`text\n"He said ""hello"""`);
43
+ });
44
+
45
+ it(`handles null values as empty strings`, () => {
46
+ const result = QueryResult.create(
47
+ [`name`, `age`],
48
+ [
49
+ { name: `Alice`, age: null },
50
+ { name: null, age: 30 },
51
+ ]
52
+ );
53
+
54
+ const buffer = CsvRenderer.render(result);
55
+ const csv = buffer.toString(`utf-8`);
56
+
57
+ expect(csv).toBe(`name,age\nAlice,\n,30`);
58
+ });
59
+
60
+ it(`handles undefined values as empty strings`, () => {
61
+ const result = QueryResult.create(
62
+ [`name`, `age`],
63
+ [{ name: `Alice`, age: undefined }]
64
+ );
65
+
66
+ const buffer = CsvRenderer.render(result);
67
+ const csv = buffer.toString(`utf-8`);
68
+
69
+ expect(csv).toBe(`name,age\nAlice,`);
70
+ });
71
+
72
+ it(`encodes output as UTF-8 with accented characters`, () => {
73
+ const result = QueryResult.create(
74
+ [`nom`, `email`],
75
+ [
76
+ { nom: `François`, email: `françois@example.fr` },
77
+ { nom: `José`, email: `josé@example.es` },
78
+ ]
79
+ );
80
+
81
+ const buffer = CsvRenderer.render(result);
82
+ const csv = buffer.toString(`utf-8`);
83
+
84
+ expect(csv).toBe(
85
+ `nom,email\nFrançois,françois@example.fr\nJosé,josé@example.es`
86
+ );
87
+ });
88
+
89
+ it(`escapes values containing newlines`, () => {
90
+ const result = QueryResult.create(
91
+ [`text`],
92
+ [{ text: `Line 1\nLine 2` }]
93
+ );
94
+
95
+ const buffer = CsvRenderer.render(result);
96
+ const csv = buffer.toString(`utf-8`);
97
+
98
+ expect(csv).toBe(`text\n"Line 1\nLine 2"`);
99
+ });
100
+
101
+ it(`escapes values containing carriage returns`, () => {
102
+ const result = QueryResult.create(
103
+ [`text`],
104
+ [{ text: `Line 1\rLine 2` }]
105
+ );
106
+
107
+ const buffer = CsvRenderer.render(result);
108
+ const csv = buffer.toString(`utf-8`);
109
+
110
+ expect(csv).toBe(`text\n"Line 1\rLine 2"`);
111
+ });
112
+
113
+ it(`handles complex escaping with multiple special characters`, () => {
114
+ const result = QueryResult.create(
115
+ [`data`],
116
+ [{ data: `"Value", with, "quotes" and, commas` }]
117
+ );
118
+
119
+ const buffer = CsvRenderer.render(result);
120
+ const csv = buffer.toString(`utf-8`);
121
+
122
+ expect(csv).toBe(
123
+ `data\n"""Value"", with, ""quotes"" and, commas"`
124
+ );
125
+ });
126
+
127
+ it(`returns valid UTF-8 buffer`, () => {
128
+ const result = QueryResult.create(
129
+ [`name`],
130
+ [{ name: `Test` }]
131
+ );
132
+
133
+ const buffer = CsvRenderer.render(result);
134
+
135
+ expect(buffer).toBeInstanceOf(Buffer);
136
+ expect(buffer.toString(`utf-8`)).toBe(`name\nTest`);
137
+ });
138
+
139
+ it(`handles empty rows array`, () => {
140
+ const result = QueryResult.create([`name`, `age`], []);
141
+
142
+ const buffer = CsvRenderer.render(result);
143
+ const csv = buffer.toString(`utf-8`);
144
+
145
+ expect(csv).toBe(`name,age`);
146
+ });
147
+
148
+ it(`handles zero and false values correctly`, () => {
149
+ const result = QueryResult.create(
150
+ [`count`, `active`],
151
+ [
152
+ { count: 0, active: false },
153
+ { count: 100, active: true },
154
+ ]
155
+ );
156
+
157
+ const buffer = CsvRenderer.render(result);
158
+ const csv = buffer.toString(`utf-8`);
159
+
160
+ expect(csv).toBe(`count,active\n0,false\n100,true`);
161
+ });
162
+ });
@@ -0,0 +1,34 @@
1
+ import type { QueryResult } from "@/domain/value-objects/QueryResult";
2
+
3
+ const escape = (value: unknown): string => {
4
+ if (value === null || value === undefined) {
5
+ return ``;
6
+ }
7
+ // eslint-disable-next-line @typescript-eslint/no-base-to-string
8
+ const str = typeof value === `string` ? value : String(value);
9
+ if (
10
+ str.includes(`,`) ||
11
+ str.includes(`"`) ||
12
+ str.includes(`\n`) ||
13
+ str.includes(`\r`)
14
+ ) {
15
+ return `"${str.replace(/"/g, `""`)}"`;
16
+ }
17
+ return str;
18
+ };
19
+
20
+ const render = (result: QueryResult): Buffer => {
21
+ const { columns, rows } = result;
22
+
23
+ const header = columns.map(escape).join(`,`);
24
+ const lines = rows.map((row) =>
25
+ columns.map((col) => escape(row[col])).join(`,`)
26
+ );
27
+
28
+ const csv = [header, ...lines].join(`\n`);
29
+ return Buffer.from(csv, `utf-8`);
30
+ };
31
+
32
+ const CsvRenderer = { render };
33
+
34
+ export default CsvRenderer;