@akanjs/devkit 2.1.1-rc.2 → 2.1.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/aiEditor.test.ts +68 -0
- package/aiEditor.ts +82 -28
- package/executors.ts +625 -147
- package/index.ts +1 -1
- package/linter.ts +308 -97
- package/package.json +2 -2
- package/prompter.ts +17 -4
- package/typecheck/typecheck.proc.ts +21 -0
package/aiEditor.test.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
parseTypescriptFileBlocks,
|
|
4
|
+
preserveTypescriptResponseContent,
|
|
5
|
+
} from "./aiEditor";
|
|
6
|
+
|
|
7
|
+
describe("parseTypescriptFileBlocks", () => {
|
|
8
|
+
test("parses TypeScript file blocks with common fence variants", () => {
|
|
9
|
+
const writes = parseTypescriptFileBlocks(`
|
|
10
|
+
\`\`\`ts
|
|
11
|
+
|
|
12
|
+
// File: lib/car/car.constant.ts
|
|
13
|
+
|
|
14
|
+
export const car = "car";
|
|
15
|
+
\`\`\`
|
|
16
|
+
|
|
17
|
+
\`\`\`tsx
|
|
18
|
+
// File: lib/car/Car.Unit.tsx
|
|
19
|
+
export const CarUnit = () => null;
|
|
20
|
+
\`\`\`
|
|
21
|
+
`);
|
|
22
|
+
|
|
23
|
+
expect(writes).toEqual([
|
|
24
|
+
{
|
|
25
|
+
filePath: "lib/car/car.constant.ts",
|
|
26
|
+
content: 'export const car = "car";',
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
filePath: "lib/car/Car.Unit.tsx",
|
|
30
|
+
content: "export const CarUnit = () => null;",
|
|
31
|
+
},
|
|
32
|
+
]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("keeps previous code response when validation responds with prose only", () => {
|
|
36
|
+
const previousContent = `
|
|
37
|
+
\`\`\`typescript
|
|
38
|
+
// File: lib/car/car.constant.ts
|
|
39
|
+
export const car = "car";
|
|
40
|
+
\`\`\`
|
|
41
|
+
`;
|
|
42
|
+
const nextContent =
|
|
43
|
+
"The generated file meets all specified requirements. No rewrite is necessary.";
|
|
44
|
+
|
|
45
|
+
expect(
|
|
46
|
+
preserveTypescriptResponseContent(previousContent, nextContent),
|
|
47
|
+
).toBe(previousContent);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("uses next code response when validation rewrites with parseable files", () => {
|
|
51
|
+
const previousContent = `
|
|
52
|
+
\`\`\`typescript
|
|
53
|
+
// File: lib/car/car.constant.ts
|
|
54
|
+
export const car = "car";
|
|
55
|
+
\`\`\`
|
|
56
|
+
`;
|
|
57
|
+
const nextContent = `
|
|
58
|
+
\`\`\`typescript
|
|
59
|
+
// File: lib/car/car.constant.ts
|
|
60
|
+
export const car = "updated";
|
|
61
|
+
\`\`\`
|
|
62
|
+
`;
|
|
63
|
+
|
|
64
|
+
expect(
|
|
65
|
+
preserveTypescriptResponseContent(previousContent, nextContent),
|
|
66
|
+
).toBe(nextContent);
|
|
67
|
+
});
|
|
68
|
+
});
|
package/aiEditor.ts
CHANGED
|
@@ -31,8 +31,41 @@ interface EditOptions {
|
|
|
31
31
|
maxTry?: number;
|
|
32
32
|
validate?: string[];
|
|
33
33
|
approve?: boolean;
|
|
34
|
+
fallbackToPreviousTypescript?: boolean;
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
export const parseTypescriptFileBlocks = (text: string): FileContent[] => {
|
|
38
|
+
const fileBlocks: FileContent[] = [];
|
|
39
|
+
const codeBlockRegex = /```(?:typescript|ts|tsx)\s*\n([\s\S]*?)```/gi;
|
|
40
|
+
const filePathRegex = /^\s*\/\/\s*File:\s*(.+?)\s*$/im;
|
|
41
|
+
|
|
42
|
+
for (const codeBlock of text.matchAll(codeBlockRegex)) {
|
|
43
|
+
const content = codeBlock[1]?.trim();
|
|
44
|
+
if (!content) continue;
|
|
45
|
+
|
|
46
|
+
const filePath = filePathRegex.exec(content)?.[1]?.trim();
|
|
47
|
+
if (!filePath) continue;
|
|
48
|
+
|
|
49
|
+
fileBlocks.push({
|
|
50
|
+
filePath,
|
|
51
|
+
content: content.replace(filePathRegex, "").trim(),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return fileBlocks;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const preserveTypescriptResponseContent = (
|
|
59
|
+
previousContent: string,
|
|
60
|
+
nextContent: string,
|
|
61
|
+
) => {
|
|
62
|
+
const previousWrites = parseTypescriptFileBlocks(previousContent);
|
|
63
|
+
const nextWrites = parseTypescriptFileBlocks(nextContent);
|
|
64
|
+
if (previousWrites.length > 0 && nextWrites.length === 0)
|
|
65
|
+
return previousContent;
|
|
66
|
+
return nextContent;
|
|
67
|
+
};
|
|
68
|
+
|
|
36
69
|
export class AiSession {
|
|
37
70
|
static #cacheDir = "node_modules/.cache/akan/aiSession";
|
|
38
71
|
static #chat: ChatDeepSeek | ChatOpenAI | null = null;
|
|
@@ -189,8 +222,7 @@ export class AiSession {
|
|
|
189
222
|
this.messageHistory.push(humanMessage);
|
|
190
223
|
const stream = await AiSession.#chat.stream(this.messageHistory);
|
|
191
224
|
let reasoningResponse = "",
|
|
192
|
-
fullResponse = ""
|
|
193
|
-
tokenIdx = 0;
|
|
225
|
+
fullResponse = "";
|
|
194
226
|
for await (const chunk of stream) {
|
|
195
227
|
if (loader.isSpinning())
|
|
196
228
|
loader.succeed(`${AiSession.#chat.model} responded`);
|
|
@@ -214,13 +246,12 @@ export class AiSession {
|
|
|
214
246
|
fullResponse += content;
|
|
215
247
|
onChunk(content); // Send individual chunks to callback
|
|
216
248
|
}
|
|
217
|
-
tokenIdx++;
|
|
218
249
|
}
|
|
219
250
|
fullResponse += "\n";
|
|
220
251
|
onChunk("\n");
|
|
221
252
|
this.messageHistory.push(new AIMessage(fullResponse));
|
|
222
253
|
return { content: fullResponse, messageHistory: this.messageHistory };
|
|
223
|
-
} catch
|
|
254
|
+
} catch {
|
|
224
255
|
loader.fail(`${AiSession.#chat.model} failed to respond`);
|
|
225
256
|
throw new Error("Failed to stream response");
|
|
226
257
|
}
|
|
@@ -233,6 +264,7 @@ export class AiSession {
|
|
|
233
264
|
maxTry = MAX_ASK_TRY,
|
|
234
265
|
validate,
|
|
235
266
|
approve,
|
|
267
|
+
fallbackToPreviousTypescript,
|
|
236
268
|
}: EditOptions = {},
|
|
237
269
|
) {
|
|
238
270
|
for (let tryCount = 0; tryCount < maxTry; tryCount++) {
|
|
@@ -240,7 +272,19 @@ export class AiSession {
|
|
|
240
272
|
if (validate?.length && tryCount === 0) {
|
|
241
273
|
const validateQuestion = `Double check if the response meets the requirements and conditions, and follow the instructions. If not, rewrite it.
|
|
242
274
|
${validate.map((v) => `- ${v}`).join("\n")}`;
|
|
243
|
-
|
|
275
|
+
const validateResponse = await this.ask(validateQuestion, {
|
|
276
|
+
onChunk,
|
|
277
|
+
onReasoning,
|
|
278
|
+
});
|
|
279
|
+
response = {
|
|
280
|
+
...validateResponse,
|
|
281
|
+
content: fallbackToPreviousTypescript
|
|
282
|
+
? preserveTypescriptResponseContent(
|
|
283
|
+
response.content,
|
|
284
|
+
validateResponse.content,
|
|
285
|
+
)
|
|
286
|
+
: validateResponse.content,
|
|
287
|
+
};
|
|
244
288
|
}
|
|
245
289
|
const isConfirmed = approve
|
|
246
290
|
? true
|
|
@@ -287,15 +331,35 @@ ${validate.map((v) => `- ${v}`).join("\n")}`;
|
|
|
287
331
|
executor: Executor,
|
|
288
332
|
options: EditOptions = {},
|
|
289
333
|
) {
|
|
290
|
-
const content = await this.edit(question,
|
|
334
|
+
const content = await this.edit(question, {
|
|
335
|
+
...options,
|
|
336
|
+
fallbackToPreviousTypescript: true,
|
|
337
|
+
});
|
|
291
338
|
const writes = this.#getTypescriptCodes(content);
|
|
339
|
+
if (!writes.length)
|
|
340
|
+
throw new Error(
|
|
341
|
+
"No parseable TypeScript file blocks were found in the AI response. Include `// File: <path>` in each code block.",
|
|
342
|
+
);
|
|
292
343
|
for (const write of writes)
|
|
293
344
|
await executor.writeFile(write.filePath, write.content);
|
|
294
345
|
return await this.#tryFixTypescripts(writes, executor, options);
|
|
295
346
|
}
|
|
296
|
-
async #editTypescripts(
|
|
297
|
-
|
|
298
|
-
|
|
347
|
+
async #editTypescripts(
|
|
348
|
+
question: string,
|
|
349
|
+
options: EditOptions = {},
|
|
350
|
+
fallbackWrites?: FileContent[],
|
|
351
|
+
) {
|
|
352
|
+
const content = await this.edit(question, {
|
|
353
|
+
...options,
|
|
354
|
+
fallbackToPreviousTypescript: true,
|
|
355
|
+
});
|
|
356
|
+
const writes = this.#getTypescriptCodes(content);
|
|
357
|
+
if (!writes.length && fallbackWrites?.length) return fallbackWrites;
|
|
358
|
+
if (!writes.length)
|
|
359
|
+
throw new Error(
|
|
360
|
+
"No parseable TypeScript file blocks were found in the AI response. Include `// File: <path>` in each code block.",
|
|
361
|
+
);
|
|
362
|
+
return writes;
|
|
299
363
|
}
|
|
300
364
|
async #tryFixTypescripts(
|
|
301
365
|
writes: FileContent[],
|
|
@@ -309,15 +373,16 @@ ${validate.map((v) => `- ${v}`).join("\n")}`;
|
|
|
309
373
|
}).start();
|
|
310
374
|
const fileChecks = await Promise.all(
|
|
311
375
|
writes.map(async ({ filePath }) => {
|
|
312
|
-
const
|
|
313
|
-
const
|
|
314
|
-
const
|
|
315
|
-
|
|
376
|
+
const lintResult = await executor.lint(filePath, { fix: true });
|
|
377
|
+
const typeCheckResult = await executor.typeCheckAsync(filePath);
|
|
378
|
+
const hasTypeErrors = typeCheckResult.fileErrors.length > 0;
|
|
379
|
+
const hasLintErrors = lintResult.errors.length > 0;
|
|
380
|
+
const needFix = hasTypeErrors || hasLintErrors;
|
|
316
381
|
return { filePath, typeCheckResult, lintResult, needFix };
|
|
317
382
|
}),
|
|
318
383
|
);
|
|
319
|
-
const
|
|
320
|
-
if (
|
|
384
|
+
const hasAnyFix = fileChecks.some((fileCheck) => fileCheck.needFix);
|
|
385
|
+
if (hasAnyFix) {
|
|
321
386
|
loader.fail(
|
|
322
387
|
"Type checking and linting has some errors, try to fix them",
|
|
323
388
|
);
|
|
@@ -337,6 +402,7 @@ ${validate.map((v) => `- ${v}`).join("\n")}`;
|
|
|
337
402
|
validate: undefined,
|
|
338
403
|
approve: true,
|
|
339
404
|
},
|
|
405
|
+
writes,
|
|
340
406
|
);
|
|
341
407
|
for (const write of writes)
|
|
342
408
|
await executor.writeFile(write.filePath, write.content);
|
|
@@ -348,19 +414,7 @@ ${validate.map((v) => `- ${v}`).join("\n")}`;
|
|
|
348
414
|
throw new Error("Failed to create scalar");
|
|
349
415
|
}
|
|
350
416
|
#getTypescriptCodes(text: string): FileContent[] {
|
|
351
|
-
|
|
352
|
-
if (!codes) return [];
|
|
353
|
-
const result = codes.map((code) => {
|
|
354
|
-
const content = /```(typescript|tsx)([\s\S]*?)```/.exec(code)?.[2];
|
|
355
|
-
if (!content) return null;
|
|
356
|
-
const filePath = /\/\/ File: (.*?)(?:\n|$)/.exec(content)?.[1]?.trim();
|
|
357
|
-
if (!filePath) return null;
|
|
358
|
-
const contentWithoutFilepath = content
|
|
359
|
-
.replace(`// File: ${filePath}\n`, "")
|
|
360
|
-
.trim();
|
|
361
|
-
return { filePath, content: contentWithoutFilepath };
|
|
362
|
-
});
|
|
363
|
-
return result.filter((code) => code !== null) as FileContent[];
|
|
417
|
+
return parseTypescriptFileBlocks(text);
|
|
364
418
|
}
|
|
365
419
|
async editMarkdown(request: string, options: EditOptions = {}) {
|
|
366
420
|
const content = await this.edit(request, options);
|