gennady 0.5.0 → 0.5.1-next.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.
package/README.md CHANGED
@@ -20,6 +20,7 @@ npx gennady cat <path1> <path2> ...
20
20
  - 🤖 [**Commit Message**](#-commit-messages): Automatically generate clear, descriptive git commit messages from your staged changes.
21
21
  - 📝 [**review**](#-review): Instantly review your staged git changes for critical issues (logic, runtime, security).
22
22
  - 🐱 [**cat**](#-cat): Quickly display the contents of multiple files or directories at once, filtered by allowed extensions (default: .js, .ts, .tsx).
23
+ - 🛠️ **TypeScript Support**: Full TypeScript type definitions for seamless integration into TypeScript projects.
23
24
 
24
25
  ---
25
26
 
@@ -116,17 +117,69 @@ ollama serve
116
117
  Create `~/.gennadyrc` configuration file:
117
118
 
118
119
  ```json
119
- [
120
- {
121
- "url": "https://api.openai.com/v1/chat/completions",
122
- "key": "...",
123
- "model": "gpt-3.5-turbo-0125"
124
- }
125
- ]
120
+ {
121
+ "models": [
122
+ {
123
+ "model": "gpt-3.5-turbo-0125",
124
+ "url": "https://api.openai.com/v1/chat/completions",
125
+ "key": "...",
126
+ },
127
+ {
128
+ "model": "llama3:8b",
129
+ "url": "http://127.0.0.1:11434/api/generate",
130
+ }
131
+ ]
132
+ }
126
133
  ```
127
134
 
128
135
  ---
129
136
 
137
+ ## 🔌 API
138
+
139
+ Gennady provides a powerful JavaScript/TypeScript API for programmatic usage in your projects.
140
+
141
+ ### Installation
142
+
143
+ ```bash
144
+ npm install gennady
145
+ ```
146
+
147
+ ### Basic Usage
148
+
149
+ ```typescript
150
+ import { GennadyRc } from 'gennady/src/rc/rc-config';
151
+ import { AiModel } from 'gennady/src/ai/ai-model';
152
+
153
+ // Load configuration
154
+ const rc = new GennadyRc();
155
+
156
+ // Get available AI models
157
+ const models = rc.getModels();
158
+
159
+ // Create AI model instance
160
+ const aiModel = new AiModel(models[0]);
161
+
162
+ // Generate text
163
+ const [response, error] = await aiModel.generate('Hello, world!', {
164
+ temperature: 0.7,
165
+ timeout: 10000
166
+ });
167
+
168
+ if (error) {
169
+ console.error('Error:', error);
170
+ } else {
171
+ console.log('Response:', response);
172
+ }
173
+ ```
174
+
175
+ ### Available Modules
176
+
177
+ - `AiModel`: Core AI model interaction
178
+ - `GennadyRc`: Configuration management
179
+ - `unguard`: Utility functions for error handling
180
+
181
+ For complete API documentation, check the source code with detailed JSDoc comments.
182
+
130
183
  ## 🎉 Happy Coding with Gennady!
131
184
 
132
185
  > Made with 🤖 by Konstantin Lebedev
package/index.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './src/ai/ai-model';
2
+ export * from './src/rc/rc-config';
3
+ export * from './src/utils/unguard';
package/index.js CHANGED
@@ -1,3 +1,7 @@
1
+ export * from './src/ai/ai-model.js';
2
+ export * from './src/rc/rc-config.js';
3
+ export * from './src/utils/unguard.js';
4
+
1
5
  export * from './src/commit-gen/commit-gen.js';
2
6
  export * from './src/git/git-diff.js';
3
7
  export * from './src/utils/language.js';
package/llm.md ADDED
@@ -0,0 +1,82 @@
1
+ # Gennady API: Using AiModel.generate
2
+
3
+ This guide demonstrates best practices for calling `AiModel.generate` and handling its results, particularly when using TypeScript.
4
+
5
+ ## 1. Basic Usage of `AiModel.generate`
6
+
7
+ The `AiModel.generate` method returns a Promise that resolves to a tuple: `[result, error]`. You need to handle both potential outcomes.
8
+
9
+ ### ✅ GOOD: Handling the Result Tuple
10
+
11
+ This example assumes `aiModel` is an initialized instance of `AiModel`.
12
+
13
+ ```typescript
14
+ import { AiModel } from 'gennady/src/ai/ai-model';
15
+
16
+ async function generateTextWithTupleHandling(aiModel: AiModel, prompt: string) {
17
+ const [response, error] = await aiModel.generate(prompt, {
18
+ temperature: 0.7,
19
+ timeout: 10000, // Optional: timeout in milliseconds
20
+ });
21
+ if (error) {
22
+ throw new Error(`[GENERATE_TEXT_WITH_TUPLE_ERROR] [${aiModel.name}] Generate failed`, {cause: error})
23
+ }
24
+
25
+ return response;
26
+ }
27
+ ```
28
+ **Reasoning:** This approach explicitly checks for an error before attempting to use the response. This is the fundamental way to interact with functions designed with this tuple-based error handling pattern.
29
+
30
+ ## 2. Using `AiModel.generate` with `unguardOrThrow` for Cleaner Code
31
+
32
+ For scenarios where you want to simplify error handling and prefer exceptions for the error path, especially in a sequence of operations, `unguardOrThrow` is recommended.
33
+
34
+ ### ❌ BAD: Multiple Manual Error Checks in Sequential Operations
35
+
36
+ ```typescript
37
+ // ... imports and aiModel initialization ...
38
+ async function sequentialGenerationBad(aiModel: AiModel) {
39
+ const [response1, error1] = await aiModel.generate("First prompt");
40
+ if (error1) {
41
+ throw new Error(`[SEQUENTIAL_GENERATION_BAD_ERROR_1] [${aiModel.name}] Generate failed`, {cause: error1})
42
+ }
43
+ console.log("First response:", response1);
44
+
45
+ const [response2, error2] = await aiModel.generate("Second prompt using: " + response1.substring(0, 20));
46
+ if (error2) {
47
+ throw new Error(`[SEQUENTIAL_GENERATION_BAD_ERROR_2] [${aiModel.name}] Generate failed`, {cause: error2})
48
+ }
49
+ console.log("Second response:", response2);
50
+ // ... and so on
51
+ }
52
+ ```
53
+ **Reasoning:** This becomes verbose and repetitive. Each step requires its own error check and handling logic, making the main flow harder to read.
54
+
55
+ ### ✅ GOOD: Using `unguardOrThrow` for Concise Sequential Operations
56
+
57
+ ```typescript
58
+ import { AiModel } from 'gennady/src/ai/ai-model';
59
+ import { unguardOrThrow } from 'gennady/src/utils/unguard';
60
+
61
+ async function sequentialGenerationGood(aiModel: AiModel) {
62
+ try {
63
+ console.log("Attempting first generation...");
64
+ const response1 = await unguardOrThrow(aiModel.generate("Write a short poem about coding."));
65
+ console.log("First response:\n", response1);
66
+
67
+ console.log("\nAttempting second generation based on the first...");
68
+ const response2 = await unguardOrThrow(aiModel.generate(`Write a haiku based on this line: "${response1.split('\n')[0]}"`));
69
+ console.log("Second response (haiku):\n", response2);
70
+
71
+ // You can continue the chain of operations here
72
+ // const response3 = await unguardOrThrow(aiModel.generate(...));
73
+
74
+ } catch (error) {
75
+ throw new Error(`[SEQUENTIAL_GENERATION_GOOD_ERROR] [${aiModel.name}] Generate failed`, {cause: error})
76
+ }
77
+ }
78
+ ```
79
+ **Reasoning:** `unguardOrThrow` unwraps the success value or throws the error if present. This allows you to write cleaner, more linear code for the success path and handle all errors in a single `catch` block. This is particularly useful for chained asynchronous operations where an error at any point should halt the entire chain.
80
+
81
+ ---
82
+ Remember to always initialize `AiModel` with valid configuration, typically loaded via `GennadyRc`. The examples above focus on the `generate` call itself, assuming `aiModel` is ready.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gennady",
3
- "version": "0.5.0",
3
+ "version": "0.5.1-next.1",
4
4
  "author": "Konstantin Lebedev <ibnrubaxa@gmail.com>",
5
5
  "description": "Gennady — Generate Next-level Automated Description Yntelligence",
6
6
  "keywords": [
@@ -19,6 +19,7 @@
19
19
  "type": "module",
20
20
  "bin": "./cli/gennady.js",
21
21
  "main": "index.js",
22
+ "types": "index.d.ts",
22
23
  "scripts": {
23
24
  "test": "node --test"
24
25
  }
package/src/ai/ai-core.js CHANGED
@@ -1,14 +1,20 @@
1
- import { readFileSync, existsSync } from 'node:fs';
2
- import { join } from 'node:path';
3
-
4
- const DEFAULT_MODEL = 'llama3:8b';
5
- const DEFAULT_API_URL = 'http://127.0.0.1:11434/api/generate';
6
-
7
- const GENNADY_RC_FILENAME = '.gennadyrc';
1
+ import { AiModel } from './ai-model.js';
2
+ import { GennadyRc } from '../rc/rc-config.js';
3
+ import { unguardOrThrow } from '../utils/unguard.js';
8
4
 
5
+ /** @deprecated */
9
6
  export class AiCore {
10
- api = undefined;
11
- apiList = [];
7
+ /**
8
+ * List of AI Models
9
+ * @type {AiModel[]} array of #AI_MODEL_CLASS
10
+ */
11
+ #models = [];
12
+
13
+ /**
14
+ * Active AI Model
15
+ * @type {AiModel|null}
16
+ */
17
+ #activeModel = null;
12
18
 
13
19
  constructor(init) {
14
20
  this.init = {
@@ -18,39 +24,23 @@ export class AiCore {
18
24
  ...init,
19
25
  };
20
26
 
21
- // Try parse rc files
22
- [
23
- join(process.cwd(), GENNADY_RC_FILENAME),
24
- join(process.env.HOME, GENNADY_RC_FILENAME),
25
- ].find((file) => {
26
- try {
27
- if (existsSync(file)) {
28
- const items = JSON.parse(readFileSync(file).toString());
29
- if (Array.isArray(items)) {
30
- this.apiList.push(...items);
31
- } else {
32
- this.logger.warn(`Invalid "${file}" config:`, items);
33
- }
34
- }
35
- } catch (err) {
36
- this.logger.error(`Parse "${file}" error:`, err);
27
+ GennadyRc.getDefaults().forEach((rc) => {
28
+ if (rc.isValid()) {
29
+ this.#models.push(...rc.getModels().map(model => new AiModel(model)));
37
30
  }
38
31
  });
39
-
40
- // Default API
41
- this.apiList[this.init.apiUrl ? 'unshift' : 'push']({
42
- url: this.init.url || DEFAULT_API_URL,
43
- key: this.init.key,
44
- model: this.init.model || DEFAULT_MODEL,
45
- });
32
+
33
+ if (!this.#models.length) {
34
+ this.#models.push(AiModel.getDefault());
35
+ }
46
36
  }
47
37
 
48
38
  get model() {
49
- return this.api?.model || this.apiList[0].model;
39
+ return this.#models[0]?.name;
50
40
  }
51
41
 
52
42
  get apiUrl() {
53
- return this.api?.url || this.apiList[0].url;
43
+ return this.#models[0]?.url
54
44
  }
55
45
 
56
46
  get maxInputTokens() {
@@ -92,96 +82,35 @@ export class AiCore {
92
82
 
93
83
  async generate(prompt, context) {
94
84
  try {
95
- const api = await this._getApi();
96
- if (api.url.includes('completions')) {
97
- return await this._callCompletionsApi(api, prompt, context);
98
- }
99
-
100
- return await this._callGenerateApi(api, prompt, context);
101
- } catch (e) {
102
- this.logger.error(`Failed to generate LLM response:`, e);
85
+ const model = await unguardOrThrow(this.#choiceModel());
86
+ const result = await unguardOrThrow(model.generate(prompt, context));
87
+ return result;
88
+ } catch (error) {
89
+ this.logger.error(`[AI_CORE_ERROR_GENERATE] Failed to generate LLM response:`, error);
103
90
  return '';
104
91
  }
105
92
  }
106
93
 
107
- async _getApi() {
108
- if (!this.api) {
109
- // By default
110
- this.api = {url: DEFAULT_API_URL, model: DEFAULT_MODEL};
111
-
112
- for (const api of this.apiList) {
113
- try {
114
- const ctrl = new AbortController();
115
-
116
- setTimeout(() => ctrl.abort(new Error('Module timeout')), 500);
117
-
118
- const resp = await fetch(api.url, {method: 'HEAD', signal: ctrl.signal});
119
- if (resp.status >= 200 && resp.status < 500) {
120
- this.api = api;
121
- return api;
122
- }
123
- } catch {}
124
- }
125
- }
126
-
127
- return this.api;
128
- }
129
-
130
- async _callGenerateApi(api, prompt, context) {
131
- const req = await fetch(api.url, {
132
- method: 'POST',
133
- headers: {'Content-Type': 'application/json'},
134
- body: JSON.stringify({
135
- model: api.model,
136
- stream: false,
137
- prompt,
138
- context,
139
- }),
140
- });
141
- const data = await req.json();
142
- return data.response || '';
143
- }
144
-
145
- async _callCompletionsApi(api, prompt, context) {
146
- const messages = [];
147
-
148
- if (context) {
149
- messages.push({ role: 'system', content: context });
94
+ /**
95
+ * Choose active model
96
+ * @anchor AI_CORE_CHOICE_MODEL
97
+ * @returns {Promise<[AiModel, null] | [null, Error]>}
98
+ */
99
+ async #choiceModel() {
100
+ if (this.#activeModel) {
101
+ return [this.#activeModel, null];
150
102
  }
151
103
 
152
- messages.push({ role: 'user', content: prompt });
153
-
154
- const req = await fetch(api.url, {
155
- method: 'POST',
156
- headers: {
157
- 'Content-Type': 'application/json',
158
- 'Authorization': `Bearer ${api.key}`,
159
- },
160
- body: JSON.stringify({
161
- model: api.model,
162
- messages: messages,
163
- temperature: 0.1,
164
- stream: false,
165
- timeout: this.init.timeout,
166
- }),
167
- });
168
-
169
- if (!req.ok) {
170
- let errorBody = '';
171
- try {
172
- errorBody = await req.text();
173
- } catch (e) { /* ignore */ }
174
-
175
- throw new Error(`LLM completions request failed with status ${req.status}: ${errorBody}`);
104
+ for (const model of this.#models) {
105
+ const [ok, error] = await model.ping();
106
+ if (ok) {
107
+ this.#activeModel = model;
108
+ return [model, null];
109
+ } else {
110
+ this.logger.warn(`[AI_CORE_ERROR_PING_MODEL_FAIL] [${model.name}] Ping failed:`, error);
111
+ }
176
112
  }
177
113
 
178
- const data = await req.json();
179
-
180
- if (data.choices && data.choices.length > 0 && data.choices[0].message) {
181
- return data.choices[0].message.content || '';
182
- } else {
183
- this.logger.warn(style.yellow('LLM completions response structure unexpected:'), data);
184
- return '';
185
- }
114
+ return [null, new Error(`[AI_CORE_ERROR_PING_FAIL] No available models`)];
186
115
  }
187
116
  }
@@ -0,0 +1,37 @@
1
+ /** AI Model (LLM) init params #AI_MODEL_INIT */
2
+ export type AiModelInit = {
3
+ model: string;
4
+ url: string;
5
+ key?: string;
6
+ };
7
+
8
+ /** AI Model (LLM) #AI_MODEL_CLASS */
9
+ export class AiModel {
10
+ /** Default AI Model #AI_MODEL_DEFAULT */
11
+ static getDefault(): AiModel;
12
+
13
+ /** Constructor #AI_MODEL_CONSTRUCTOR */
14
+ constructor(init: AiModelInit);
15
+
16
+ /** Model identifier/name #AI_MODEL_MODEL_NAME */
17
+ readonly name: string;
18
+
19
+ /** API endpoint URL #AI_MODEL_URL */
20
+ readonly url: string;
21
+
22
+ /** API authentication key #AI_MODEL_KEY */
23
+ readonly key: string | undefined;
24
+
25
+ /** Ping AI Model #AI_MODEL_PING */
26
+ ping(timeout?: number): Promise<[boolean, null] | [null, Error]>;
27
+
28
+ /** Generate LLM response #AI_MODEL_GENERATE */
29
+ generate(
30
+ prompt: string,
31
+ init?: {
32
+ context?: string;
33
+ temperature?: number;
34
+ timeout?: number;
35
+ }
36
+ ): Promise<[string, null] | [null, Error]>;
37
+ }
@@ -0,0 +1,179 @@
1
+ import { unguardOrThrow } from "../utils/unguard.js";
2
+
3
+ /**
4
+ * AI Model (LLM) init params
5
+ * @SUPER_TOKEN AI_MODEL_INIT
6
+ * @typedef {Object} AiModelInit
7
+ * @property {string} model - Model identifier/name
8
+ * @property {string} url - API endpoint URL
9
+ * @property {string} [key] - Optional API authentication key
10
+ */
11
+
12
+ /**
13
+ * AI Model (LLM)
14
+ * @SUPER_TOKEN AI_MODEL_CLASS
15
+ */
16
+ export class AiModel {
17
+ /**
18
+ * Default AI Model
19
+ * @anchor AI_MODEL_DEFAULT
20
+ * @returns {AiModel} #AI_MODEL_CLASS
21
+ */
22
+ static getDefault() {
23
+ return new AiModel({
24
+ model: 'llama3:8b',
25
+ url: 'http://127.0.0.1:11434/api/generate',
26
+ });
27
+ }
28
+
29
+ /**
30
+ * Constructor
31
+ * @anchor AI_MODEL_CONSTRUCTOR
32
+ * @param {AiModelInit} init - Configuration object (#AI_MODEL_INIT)
33
+ */
34
+ constructor(init) {
35
+ this.#init = {
36
+ ...this.#init,
37
+ ...init,
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Configuration object
43
+ * @type {AiModelInit} #AI_MODEL_INIT
44
+ */
45
+ #init = {};
46
+
47
+ /**
48
+ * Model identifier/name
49
+ * @anchor AI_MODEL_MODEL_NAME
50
+ * @returns {string}
51
+ */
52
+ get name() {
53
+ return this.#init.model;
54
+ }
55
+
56
+ /**
57
+ * API endpoint URL
58
+ * @anchor AI_MODEL_URL
59
+ * @returns {string}
60
+ */
61
+ get url() {
62
+ return this.#init.url;
63
+ }
64
+
65
+ /**
66
+ * API authentication key
67
+ * @anchor AI_MODEL_KEY
68
+ * @returns {string}
69
+ */
70
+ get key() {
71
+ return this.#init.key;
72
+ }
73
+
74
+ /**
75
+ * Ping AI Model
76
+ * @anchor AI_MODEL_PING
77
+ * @param {number} [timeout] - Optional ping request timeout
78
+ * @returns {Promise<[boolean, null] | [null, Error]>} True if ping successful
79
+ */
80
+ async ping(timeout = 5e3) {
81
+ try {
82
+ const answer = await unguardOrThrow(this.generate('Say one token "OK"', {timeout}));
83
+ return [`${answer}`.toUpperCase() === 'OK', null];
84
+ } catch (cause) {
85
+ return [null, new Error(`[AI_MODEL_ERROR_PING] [${this.name}] Ping failed`, {cause})];
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Generate LLM response
91
+ * @anchor AI_MODEL_GENERATE
92
+ * @param {string} prompt - Prompt text
93
+ * @param {Object} [init] - Optional configuration object
94
+ * @property {string} [init.context] - Optional context text
95
+ * @property {string} [init.temperature] - Optional temperature generation
96
+ * @property {string} [init.timeout] - Optional request timeout
97
+ * @returns {Promise<[string, null] | [null, Error]>} Generated LLM response
98
+ */
99
+ async generate(prompt, init = {}) {
100
+ // Prepare fetch params
101
+ const params = this.url.includes('completions')
102
+ ? {
103
+ temperature: init.temperature ?? 0.2,
104
+ messages: [].concat(
105
+ init.context ? { role: 'system', content: init.context } : [],
106
+ { role: 'user', content: prompt },
107
+ ),
108
+ }
109
+ : {
110
+ context: init.context,
111
+ prompt,
112
+ }
113
+ ;
114
+
115
+ // Call LLM API with params
116
+ const data = await unguardOrThrow(this.#fetchAsJson(params, init.timeout));
117
+
118
+ // Generate API Response
119
+ if (data && 'response' in data) {
120
+ return [data.response || '', null];
121
+ }
122
+
123
+ // Completions API Response
124
+ if (data && data.choices && data.choices.length > 0 && data.choices[0].message) {
125
+ return [data.choices[0].message.content || '', null];
126
+ }
127
+
128
+ return [null, new Error(
129
+ `[AI_MODEL_ERROR_GENERATE] [${this.name}] Response structure unexpected`,
130
+ {cause: data},
131
+ )];
132
+ }
133
+
134
+ /**
135
+ * Fetch data from LLM API
136
+ * @anchor AI_MODEL_FETCH_AS_JSON
137
+ * @param {Object} params - Request parameters
138
+ * @param {number} [timeout] - Optional request timeout
139
+ * @returns {Promise<[unknown, null] | [null, Error]>} API response
140
+ */
141
+ async #fetchAsJson(params, timeout = 120e3) {
142
+ const controller = new AbortController();
143
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
144
+
145
+ try {
146
+ const req = await fetch(this.url, {
147
+ signal: controller.signal,
148
+ method: 'POST',
149
+ headers: {
150
+ 'Content-Type': 'application/json',
151
+ 'Authorization': this.key ? `Bearer ${this.key}` : undefined,
152
+ },
153
+ body: JSON.stringify({
154
+ ...params,
155
+ model: this.name,
156
+ stream: false,
157
+ }),
158
+ });
159
+
160
+ if (!req.ok) {
161
+ let errorBody = '';
162
+ try {
163
+ errorBody = await req.text();
164
+ } catch (_) { /* ignore */ }
165
+
166
+ return [null, new Error(
167
+ `[AI_MODEL_ERROR_FETCH_NOT_OK] [${this.name}] Fetch failed with status "${req.status}"`,
168
+ {cause: errorBody},
169
+ )];
170
+ }
171
+
172
+ return [await req.json(), null];
173
+ } catch (cause) {
174
+ return [null, new Error(`[AI_MODEL_ERROR_FETCH] [${this.name}] Fetch failed`, {cause})];
175
+ } finally {
176
+ clearTimeout(timeoutId);
177
+ }
178
+ }
179
+ }
@@ -0,0 +1,113 @@
1
+ import { describe, test, mock } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { AiModel } from './ai-model.js';
4
+
5
+ const defaultInit = {
6
+ model: 'test-model',
7
+ url: 'http://localhost/api',
8
+ key: 'test-key',
9
+ };
10
+
11
+ describe('AiModel', () => {
12
+ describe('constructor & getters', () => {
13
+ test('model, url, key', () => {
14
+ const ai = new AiModel(defaultInit);
15
+ assert.strictEqual(ai.name, defaultInit.model);
16
+ assert.strictEqual(ai.url, defaultInit.url);
17
+ assert.strictEqual(ai.key, defaultInit.key);
18
+ });
19
+ });
20
+
21
+ describe('generate()', () => {
22
+ test('response with .response field', async () => {
23
+ const mockedFetch = mock.method(global, 'fetch', async () => ({
24
+ ok: true,
25
+ json: async () => ({ response: 'hello' })
26
+ }));
27
+ try {
28
+ const ai = new AiModel(defaultInit);
29
+ const [result, error] = await ai.generate('prompt');
30
+ assert.strictEqual(error, null);
31
+ assert.strictEqual(result, 'hello');
32
+ } finally {
33
+ mockedFetch.mock.restore();
34
+ }
35
+ });
36
+
37
+ test('response with choices[0].message', async () => {
38
+ const mockedFetch = mock.method(global, 'fetch', async () => ({
39
+ ok: true,
40
+ json: async () => ({ choices: [{ message: { content: 'hi!' } }] })
41
+ }));
42
+ try {
43
+ const ai = new AiModel({ ...defaultInit, url: 'http://localhost/api/completions' });
44
+ const [result, error] = await ai.generate('prompt');
45
+ assert.strictEqual(error, null);
46
+ assert.strictEqual(result, 'hi!');
47
+ } finally {
48
+ mockedFetch.mock.restore();
49
+ }
50
+ });
51
+
52
+ test('unexpected structure', async () => {
53
+ const mockedFetch = mock.method(global, 'fetch', async () => ({
54
+ ok: true,
55
+ json: async () => ({ foo: 'bar' })
56
+ }));
57
+ try {
58
+ const ai = new AiModel(defaultInit);
59
+ const [result, error] = await ai.generate('prompt');
60
+ assert(error instanceof Error);
61
+ assert.strictEqual(result, null);
62
+ assert(String(error.message).includes('Response structure unexpected'));
63
+ } finally {
64
+ mockedFetch.mock.restore();
65
+ }
66
+ });
67
+ });
68
+
69
+ describe('ping()', () => {
70
+ test('returns true if model responds OK', async () => {
71
+ const mockedFetch = mock.method(global, 'fetch', async () => ({
72
+ ok: true,
73
+ json: async () => ({ response: 'OK' })
74
+ }));
75
+ try {
76
+ const ai = new AiModel(defaultInit);
77
+ const [ok, err] = await ai.ping();
78
+ assert.strictEqual(err, null);
79
+ assert.strictEqual(ok, true);
80
+ } finally {
81
+ mockedFetch.mock.restore();
82
+ }
83
+ });
84
+
85
+ test('returns false if model responds NOT OK', async () => {
86
+ const mockedFetch = mock.method(global, 'fetch', async () => ({
87
+ ok: true,
88
+ json: async () => ({ response: 'NOPE' })
89
+ }));
90
+ try {
91
+ const ai = new AiModel(defaultInit);
92
+ const [ok, err] = await ai.ping();
93
+ assert.strictEqual(err, null);
94
+ assert.strictEqual(ok, false);
95
+ } finally {
96
+ mockedFetch.mock.restore();
97
+ }
98
+ });
99
+
100
+ test('returns error if fetch fails', async () => {
101
+ const mockedFetch = mock.method(global, 'fetch', async () => { throw new Error('fail'); });
102
+ try {
103
+ const ai = new AiModel(defaultInit);
104
+ const [ok, err] = await ai.ping();
105
+ assert(err instanceof Error);
106
+ assert.strictEqual(ok, null);
107
+ assert(`${err}`.includes('Ping failed'));
108
+ } finally {
109
+ mockedFetch.mock.restore();
110
+ }
111
+ });
112
+ });
113
+ });
@@ -52,6 +52,12 @@ const getCategory = (filename, metadata) => {
52
52
  return category;
53
53
  };
54
54
 
55
+ /**
56
+ * Parse git diff
57
+ * @anchor GIT_DIFF_PARSE
58
+ * @param {string} diffText - Git diff text
59
+ * @returns {Object[]} Parsed git diff
60
+ */
55
61
  export const parseGitDiff = (diffText) => {
56
62
  const lines = diffText.split('\n');
57
63
  const result = [];
@@ -0,0 +1,30 @@
1
+ /** Gennady RC data #GENNADY_RC_DATA */
2
+ export type RcModel = {
3
+ model: string;
4
+ url: string;
5
+ key?: string;
6
+ };
7
+
8
+ /** Gennady RC (configuration) #GENNADY_RC_CLASS */
9
+ export class GennadyRc {
10
+ /** Default Gennady RC filename #GENNADY_RC_DEFAULT_FILENAME */
11
+ static readonly DEFAULT_FILENAME: string;
12
+
13
+ /** Get default rc configs #GENNADY_RC_GET_DEFAULTS */
14
+ static getDefaults(): GennadyRc[];
15
+
16
+ /** Load rc config #GENNADY_RC_LOAD */
17
+ static load(path?: string): GennadyRc;
18
+
19
+ /** Constructor #GENNADY_RC_CONSTRUCTOR */
20
+ constructor(dir?: string, filename?: string);
21
+
22
+ /** Check if config is valid #GENNADY_RC_METHOD_IS_VALID */
23
+ isValid(): boolean;
24
+
25
+ /** Get AI Models config #GENNADY_RC_METHOD_GET_MODELS */
26
+ getModels(): RcModel[];
27
+
28
+ /** Get config parse error #GENNADY_RC_GET_ERROR */
29
+ getError(): Error | null;
30
+ }
@@ -0,0 +1,122 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { join as pathJoin } from 'node:path';
3
+
4
+ /**
5
+ * Gennady RC data
6
+ * @SUPER_TOKEN GENNADY_RC_DATA
7
+ * @typedef {Object} GennadyRcData
8
+ * @property {AiModelInit[]} models - AI Models configs #AI_MODEL_INIT
9
+ */
10
+
11
+ /**
12
+ * Gennady RC (configuration)
13
+ * @SUPER_TOKEN GENNADY_RC_CLASS
14
+ */
15
+ export class GennadyRc {
16
+ /**
17
+ * Default Gennady RC filename
18
+ * @anchor GENNADY_RC_DEFAULT_FILENAME
19
+ * @constant {string}
20
+ */
21
+ static DEFAULT_FILENAME = '.gennadyrc';
22
+
23
+ /**
24
+ * Get default rc configs
25
+ * @anchor GENNADY_RC_GET_DEFAULTS
26
+ * @returns {GennadyRc[]}
27
+ */
28
+ static getDefaults() {
29
+ return [process.cwd(), process.env.HOME].map((dir) => {
30
+ return new GennadyRc(dir);
31
+ });
32
+ }
33
+
34
+ /**
35
+ * Config filename
36
+ * @anchor GENNADY_RC_FILENAME
37
+ * @type {string}
38
+ */
39
+ #filename = '';
40
+
41
+ /**
42
+ * Config data
43
+ * @anchor GENNADY_RC_DATA
44
+ * @type {GennadyRcData} #GENNADY_RC_DATA
45
+ */
46
+ #data = {
47
+ models: [],
48
+ };
49
+
50
+ /**
51
+ * Config parse error
52
+ * @anchor GENNADY_RC_ERROR
53
+ * @type {Error|null}
54
+ */
55
+ #error = null;
56
+
57
+ /**
58
+ * Constructor
59
+ * @anchor GENNADY_RC_CONSTRUCTOR
60
+ * @param {string} [dir] - Optional rc-config directory (default process.cwd())
61
+ * @param {string} [filename] - Optional rc-config filename (default #GENNADY_RC_FILENAME)
62
+ */
63
+ constructor(
64
+ dir = process.cwd(),
65
+ name = GennadyRc.DEFAULT_FILENAME,
66
+ ) {
67
+ this.#filename = pathJoin(dir, name);
68
+
69
+ try {
70
+ if (existsSync(this.#filename)) {
71
+ const data = JSON.parse(readFileSync(this.#filename).toString());
72
+
73
+ if (Array.isArray(data)) {
74
+ // Legacy format
75
+ this.#data.models = data;
76
+ } else if (data && Array.isArray(data.models)) {
77
+ this.#data = {
78
+ ...this.#data,
79
+ ...data,
80
+ };
81
+ } else {
82
+ this.#error = new Error(
83
+ `[GENNADY_RC_ERROR_CONFIG] Invalid "${this.#filename}" config data`,
84
+ {cause: data},
85
+ );
86
+ }
87
+ }
88
+ } catch (err) {
89
+ this.#error = new Error(
90
+ `[GENNADY_RC_ERROR_PARSE] Read "${this.#filename}" config failed`,
91
+ {cause: err},
92
+ );
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Check if config is valid
98
+ * @anchor GENNADY_RC_METHOD_IS_VALID
99
+ * @returns {boolean}
100
+ */
101
+ isValid() {
102
+ return !this.#error;
103
+ }
104
+
105
+ /**
106
+ * Get AI Models config
107
+ * @anchor GENNADY_RC_METHOD_GET_MODELS
108
+ * @returns {AiModelInit[]} array of #AI_MODEL_INIT
109
+ */
110
+ getModels() {
111
+ return this.#data.models;
112
+ }
113
+
114
+ /**
115
+ * Get config parse error
116
+ * @anchor GENNADY_RC_GET_ERROR
117
+ * @returns {Error|null}
118
+ */
119
+ getError() {
120
+ return this.#error;
121
+ }
122
+ }
@@ -0,0 +1,61 @@
1
+ import { describe, test, beforeEach, afterEach } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { GennadyRc } from './rc-config.js';
7
+
8
+ let tempDir;
9
+
10
+ beforeEach(() => {
11
+ tempDir = mkdtempSync(join(tmpdir(), 'gennadyrc-test-'));
12
+ });
13
+
14
+ afterEach(() => {
15
+ try {
16
+ rmSync(tempDir, { recursive: true, force: true });
17
+ } catch {}
18
+ });
19
+
20
+ describe('GennadyRC', () => {
21
+ test('Valid config: object with models', () => {
22
+ const config = { models: [{ model: 'test-model', url: 'http://localhost' }] };
23
+ const file = join(tempDir, '.gennadyrc');
24
+ writeFileSync(file, JSON.stringify(config));
25
+ const rc = new GennadyRc(tempDir);
26
+ assert.strictEqual(rc.isValid(), true, 'Config should be valid');
27
+ assert.deepStrictEqual(rc.getModels(), config.models, 'Models should match');
28
+ });
29
+
30
+ test('Legacy config: array of models', () => {
31
+ const config = [{ model: 'legacy', url: 'http://legacy' }];
32
+ const file = join(tempDir, '.gennadyrc');
33
+ writeFileSync(file, JSON.stringify(config));
34
+ const rc = new GennadyRc(tempDir);
35
+ assert.strictEqual(rc.isValid(), true, 'Legacy config should be valid');
36
+ assert.deepStrictEqual(rc.getModels(), config, 'Legacy models should match');
37
+ });
38
+
39
+ test('Invalid JSON', () => {
40
+ const file = join(tempDir, '.gennadyrc');
41
+ writeFileSync(file, '{ invalid json');
42
+ const rc = new GennadyRc(tempDir);
43
+ assert.strictEqual(rc.isValid(), false, 'Config with invalid JSON should be invalid');
44
+ assert(rc.getError() instanceof Error, 'Should have error');
45
+ });
46
+
47
+ test('Invalid structure: object without models', () => {
48
+ const config = { foo: 'bar' };
49
+ const file = join(tempDir, '.gennadyrc');
50
+ writeFileSync(file, JSON.stringify(config));
51
+ const rc = new GennadyRc(tempDir);
52
+ assert.strictEqual(rc.isValid(), false, 'Config without models should be invalid');
53
+ assert(rc.getError() instanceof Error, 'Should have error');
54
+ });
55
+
56
+ test('Missing file', () => {
57
+ const rc = new GennadyRc(tempDir);
58
+ assert.strictEqual(rc.isValid(), true, 'Config should be valid if file is missing');
59
+ assert.deepStrictEqual(rc.getModels(), [], 'Models should be empty if file is missing');
60
+ });
61
+ });
@@ -5,7 +5,7 @@ import { test, describe } from 'node:test';
5
5
  import assert from 'node:assert';
6
6
  import { ReviewGen } from './review-gen.js';
7
7
 
8
- describe('review', async () => {
8
+ describe.skip('review', async () => {
9
9
  const reviewGen = new ReviewGen();
10
10
  const fixtures = getFixture();
11
11
 
@@ -0,0 +1,9 @@
1
+ /** Synchronously unwraps a result tuple #UNGUARD_FUNCTION */
2
+ declare function unguard<TResult>(result: [TResult, null] | [null, Error]): TResult;
3
+
4
+ /** Asynchronously unwraps a result, which can be either a direct tuple `[result, error]` #UNGUARD_OR_THROW_FUNCTION */
5
+ declare function unguardOrThrow<TResult>(
6
+ resultOrPromise: [TResult, null] | [null, Error] | Promise<[TResult, null] | [null, Error]>
7
+ ): Promise<TResult>;
8
+
9
+ export { unguard, unguardOrThrow };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Synchronously unwraps a result tuple.
3
+ * If the tuple is malformed or indicates an error (assuming the error is already an Error instance),
4
+ * this function throws that error. Otherwise, it returns the successful result.
5
+ *
6
+ * @template TResult The type of the result expected if the tuple represents a successful outcome.
7
+ *
8
+ * @param {unknown} resultTuple - The input, expected to be a tuple `[result, error]`.
9
+ * Specifically, `[TResult, null]` for success, or `[null, Error]` for failure.
10
+ *
11
+ * @returns {TResult} The unwrapped result if the tuple represents a successful outcome.
12
+ * @throws {Error} - Throws the error from `resultTuple[1]` if present.
13
+ * - Throws a generic `Error` if `resultTuple` is not a valid 2-element array.
14
+ */
15
+
16
+ const unguard = (result) => {
17
+ if (Array.isArray(result) && result.length === 2) {
18
+ const error = result[1];
19
+
20
+ if (error === null) {
21
+ return result[0];
22
+ } else {
23
+ throw error instanceof Error
24
+ ? error
25
+ : new TypeError(`[UNGUARD_ERROR_TYPE] Error "${error}"`, {cause: error})
26
+ ;
27
+ }
28
+ }
29
+
30
+ throw new SyntaxError('[UNGUARD_ERROR_SYNTAX] Invalid input: not a valid [result, error] tuple.', { cause: result });
31
+ }
32
+
33
+ /**
34
+ * Asynchronously unwraps a result, which can be either a direct tuple `[result, error]`
35
+ * or a Promise resolving to such a tuple (typically from `guardedCall`).
36
+ * If the outcome indicates an error (either directly or after the Promise resolves),
37
+ * this function throws that error. Otherwise, it returns the successful result.
38
+ *
39
+ * @async
40
+ * @template TResult The type of the result expected if the operation was successful.
41
+ *
42
+ * @param {[TResult, null] | [null, Error] | Promise<([TResult, null] | [null, Error])>} resultOrPromise
43
+ * The outcome to unwrap. This can be:
44
+ * 1. A direct tuple: `[successfulResult, null]`
45
+ * 2. A direct tuple: `[null, errorInstance]`
46
+ * 3. A Promise that resolves to one of the above tuples.
47
+ *
48
+ * @returns {Promise<TResult>} A Promise that resolves to the unwrapped successful result.
49
+ * @throws {Error} - Throws the error from the tuple if present (either directly or after
50
+ * the Promise resolves).
51
+ * - Throws a generic `Error` if the resolved value from a Promise is not
52
+ * a valid 2-element `[result, error]` tuple.
53
+ */
54
+ export const unguardOrThrow = async (resultOrPromise) => {
55
+ const result = await resultOrPromise;
56
+ return unguard(result);
57
+ };