newpr 1.0.17 → 1.0.18
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/package.json +1 -1
- package/src/llm/agent-client.ts +53 -0
- package/src/llm/resilient-client.ts +79 -0
- package/src/stack/balance.ts +4 -2
- package/src/stack/json-utils.ts +112 -0
- package/src/stack/partition.ts +34 -9
- package/src/stack/pr-title.ts +25 -19
- package/src/stack/publish.ts +439 -25
- package/src/stack/quality-gate.ts +264 -0
- package/src/stack/split.ts +6 -3
- package/src/telemetry/index.ts +13 -2
- package/src/web/client/hooks/useStack.ts +110 -5
- package/src/web/client/panels/StackPanel.tsx +62 -3
- package/src/web/server/routes.ts +120 -2
- package/src/web/server/stack-manager.ts +85 -2
- package/src/web/server.ts +6 -0
- package/src/web/styles/built.css +1 -1
- package/src/workspace/agent.ts +14 -4
package/package.json
CHANGED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { AgentToolName } from "../workspace/types.ts";
|
|
2
|
+
import { getAvailableAgents, runAgentWithFallback } from "../workspace/agent.ts";
|
|
3
|
+
import type { LlmClient, LlmResponse, StreamChunkCallback } from "./client.ts";
|
|
4
|
+
|
|
5
|
+
function buildPrompt(systemPrompt: string, userPrompt: string): string {
|
|
6
|
+
return `<system>\n${systemPrompt}\n</system>\n\n${userPrompt}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function createAgentLlmClient(timeoutSeconds: number, preferredAgent?: AgentToolName): LlmClient {
|
|
10
|
+
let agentsPromise: Promise<Awaited<ReturnType<typeof getAvailableAgents>>> | null = null;
|
|
11
|
+
|
|
12
|
+
const ensureAgents = async () => {
|
|
13
|
+
if (!agentsPromise) {
|
|
14
|
+
agentsPromise = getAvailableAgents(preferredAgent);
|
|
15
|
+
}
|
|
16
|
+
return agentsPromise;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const runComplete = async (systemPrompt: string, userPrompt: string): Promise<LlmResponse> => {
|
|
20
|
+
const agents = await ensureAgents();
|
|
21
|
+
const prompt = buildPrompt(systemPrompt, userPrompt);
|
|
22
|
+
const result = await runAgentWithFallback(agents, process.cwd(), prompt, {
|
|
23
|
+
timeout: timeoutSeconds * 1000,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const content = result.answer.trim();
|
|
27
|
+
if (!content) {
|
|
28
|
+
throw new Error("Agent returned empty response");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
content,
|
|
33
|
+
model: `agent:${result.tool_used}`,
|
|
34
|
+
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
async complete(systemPrompt: string, userPrompt: string): Promise<LlmResponse> {
|
|
40
|
+
return runComplete(systemPrompt, userPrompt);
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
async completeStream(
|
|
44
|
+
systemPrompt: string,
|
|
45
|
+
userPrompt: string,
|
|
46
|
+
onChunk: StreamChunkCallback,
|
|
47
|
+
): Promise<LlmResponse> {
|
|
48
|
+
const response = await runComplete(systemPrompt, userPrompt);
|
|
49
|
+
onChunk(response.content, response.content);
|
|
50
|
+
return response;
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { AgentToolName } from "../workspace/types.ts";
|
|
2
|
+
import type { LlmClient, LlmClientOptions, LlmResponse, StreamChunkCallback } from "./client.ts";
|
|
3
|
+
import { createLlmClient } from "./client.ts";
|
|
4
|
+
import { createAgentLlmClient } from "./agent-client.ts";
|
|
5
|
+
|
|
6
|
+
interface ResilientClientOptions {
|
|
7
|
+
preferredAgent?: AgentToolName;
|
|
8
|
+
onFallback?: (reason: string) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function isOpenRouter401UserNotFound(error: unknown): boolean {
|
|
12
|
+
if (!(error instanceof Error)) return false;
|
|
13
|
+
return /OpenRouter API error\s*401/i.test(error.message) && /User not found/i.test(error.message);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isOpenRouterJsonParseFailure(error: unknown): boolean {
|
|
17
|
+
if (!(error instanceof Error)) return false;
|
|
18
|
+
return /JSON Parse error/i.test(error.message)
|
|
19
|
+
|| /Unexpected token/i.test(error.message)
|
|
20
|
+
|| /Unterminated string/i.test(error.message);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createResilientLlmClient(
|
|
24
|
+
options: LlmClientOptions,
|
|
25
|
+
resilientOptions: ResilientClientOptions = {},
|
|
26
|
+
): LlmClient {
|
|
27
|
+
const primary = createLlmClient(options);
|
|
28
|
+
let fallback: LlmClient | null = null;
|
|
29
|
+
let fallbackNotified = false;
|
|
30
|
+
|
|
31
|
+
const ensureFallback = (): LlmClient => {
|
|
32
|
+
if (!fallback) {
|
|
33
|
+
fallback = createAgentLlmClient(options.timeout, resilientOptions.preferredAgent);
|
|
34
|
+
}
|
|
35
|
+
return fallback;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const notifyFallback = (reason: string): void => {
|
|
39
|
+
if (fallbackNotified) return;
|
|
40
|
+
fallbackNotified = true;
|
|
41
|
+
resilientOptions.onFallback?.(reason);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const tryWithFallback = async (run: (client: LlmClient) => Promise<LlmResponse>): Promise<LlmResponse> => {
|
|
45
|
+
try {
|
|
46
|
+
return await run(primary);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (!options.api_key) {
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (isOpenRouter401UserNotFound(error)) {
|
|
53
|
+
notifyFallback("OpenRouter authentication failed (401 User not found)");
|
|
54
|
+
return run(ensureFallback());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (isOpenRouterJsonParseFailure(error)) {
|
|
58
|
+
notifyFallback("OpenRouter response parsing failed");
|
|
59
|
+
return run(ensureFallback());
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
async complete(systemPrompt: string, userPrompt: string): Promise<LlmResponse> {
|
|
68
|
+
return tryWithFallback((client) => client.complete(systemPrompt, userPrompt));
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
async completeStream(
|
|
72
|
+
systemPrompt: string,
|
|
73
|
+
userPrompt: string,
|
|
74
|
+
onChunk: StreamChunkCallback,
|
|
75
|
+
): Promise<LlmResponse> {
|
|
76
|
+
return tryWithFallback((client) => client.completeStream(systemPrompt, userPrompt, onChunk));
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
package/src/stack/balance.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { LlmClient } from "../llm/client.ts";
|
|
2
2
|
import type { FileGroup } from "../types/output.ts";
|
|
3
3
|
import type { StackWarning } from "./types.ts";
|
|
4
|
+
import { safeParseJson } from "./json-utils.ts";
|
|
4
5
|
|
|
5
6
|
export interface BalanceResult {
|
|
6
7
|
ownership: Map<string, string>;
|
|
@@ -90,8 +91,9 @@ If no moves make sense, return: { "moves": [] }`;
|
|
|
90
91
|
|
|
91
92
|
try {
|
|
92
93
|
const response = await llmClient.complete(system, user);
|
|
93
|
-
const
|
|
94
|
-
|
|
94
|
+
const result = safeParseJson<{ moves: Array<{ path: string; to: string; reason: string }> }>(response.content);
|
|
95
|
+
if (!result.ok) throw new Error(result.error);
|
|
96
|
+
const parsed = result.data;
|
|
95
97
|
|
|
96
98
|
const validGroupNames = new Set(groups.map((g) => g.name));
|
|
97
99
|
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
export type SafeParseResult<T = unknown> =
|
|
2
|
+
| { ok: true; data: T }
|
|
3
|
+
| { ok: false; error: string };
|
|
4
|
+
|
|
5
|
+
function stripNonJsonArtifacts(raw: string): string {
|
|
6
|
+
return raw
|
|
7
|
+
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "")
|
|
8
|
+
.replace(/[\u001b\u009b]/g, "")
|
|
9
|
+
.replace(/[\u2580-\u259F]/g, "")
|
|
10
|
+
.replace(/[^\x20-\x7E\u00A0-\uFFFF\n\r\t]/g, "");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function extractCodeBlock(text: string): string | null {
|
|
14
|
+
const match = text.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/);
|
|
15
|
+
return match?.[1]?.trim() ?? null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function findJsonBoundaries(source: string): string | null {
|
|
19
|
+
const objIdx = source.indexOf("{");
|
|
20
|
+
const arrIdx = source.indexOf("[");
|
|
21
|
+
let start = -1;
|
|
22
|
+
if (objIdx >= 0 && arrIdx >= 0) start = Math.min(objIdx, arrIdx);
|
|
23
|
+
else start = Math.max(objIdx, arrIdx);
|
|
24
|
+
if (start < 0) return null;
|
|
25
|
+
|
|
26
|
+
let inString = false;
|
|
27
|
+
let escaped = false;
|
|
28
|
+
const stack: string[] = [];
|
|
29
|
+
|
|
30
|
+
for (let i = start; i < source.length; i++) {
|
|
31
|
+
const ch = source[i]!;
|
|
32
|
+
if (inString) {
|
|
33
|
+
if (escaped) { escaped = false; continue; }
|
|
34
|
+
if (ch === "\\") { escaped = true; continue; }
|
|
35
|
+
if (ch === '"') inString = false;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (ch === '"') { inString = true; continue; }
|
|
40
|
+
if (ch === "{" || ch === "[") { stack.push(ch); continue; }
|
|
41
|
+
|
|
42
|
+
if (ch === "}" || ch === "]") {
|
|
43
|
+
const top = stack[stack.length - 1];
|
|
44
|
+
if ((top === "{" && ch === "}") || (top === "[" && ch === "]")) {
|
|
45
|
+
stack.pop();
|
|
46
|
+
if (stack.length === 0) {
|
|
47
|
+
return source.slice(start, i + 1);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function repairTruncatedJson(text: string): string {
|
|
57
|
+
let depth = 0;
|
|
58
|
+
let inStr = false;
|
|
59
|
+
let esc = false;
|
|
60
|
+
for (let i = 0; i < text.length; i++) {
|
|
61
|
+
const ch = text[i]!;
|
|
62
|
+
if (inStr) {
|
|
63
|
+
if (esc) { esc = false; continue; }
|
|
64
|
+
if (ch === "\\") { esc = true; continue; }
|
|
65
|
+
if (ch === '"') inStr = false;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (ch === '"') { inStr = true; continue; }
|
|
69
|
+
if (ch === "{" || ch === "[") depth++;
|
|
70
|
+
if (ch === "}" || ch === "]") depth--;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let repaired = text;
|
|
74
|
+
if (inStr) repaired += '"';
|
|
75
|
+
while (depth > 0) {
|
|
76
|
+
repaired += "}";
|
|
77
|
+
depth--;
|
|
78
|
+
}
|
|
79
|
+
return repaired;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function extractJsonFromText(raw: string): string {
|
|
83
|
+
const cleaned = stripNonJsonArtifacts(raw);
|
|
84
|
+
|
|
85
|
+
const fromBlock = extractCodeBlock(cleaned);
|
|
86
|
+
if (fromBlock) {
|
|
87
|
+
const bounded = findJsonBoundaries(fromBlock);
|
|
88
|
+
if (bounded) return bounded;
|
|
89
|
+
return fromBlock;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const bounded = findJsonBoundaries(cleaned);
|
|
93
|
+
if (bounded) return bounded;
|
|
94
|
+
|
|
95
|
+
return cleaned.trim();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function safeParseJson<T = unknown>(raw: string): SafeParseResult<T> {
|
|
99
|
+
const extracted = extractJsonFromText(raw);
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
return { ok: true, data: JSON.parse(extracted) as T };
|
|
103
|
+
} catch {
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const repaired = repairTruncatedJson(extracted);
|
|
108
|
+
return { ok: true, data: JSON.parse(repaired) as T };
|
|
109
|
+
} catch (err) {
|
|
110
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
111
|
+
}
|
|
112
|
+
}
|
package/src/stack/partition.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { FileGroup } from "../types/output.ts";
|
|
|
2
2
|
import type { PrCommit } from "../types/github.ts";
|
|
3
3
|
import type { LlmClient } from "../llm/client.ts";
|
|
4
4
|
import type { PartitionResult, ReattributedFile, StackWarning } from "./types.ts";
|
|
5
|
+
import { safeParseJson } from "./json-utils.ts";
|
|
5
6
|
|
|
6
7
|
interface FileSummaryInput {
|
|
7
8
|
path: string;
|
|
@@ -135,9 +136,36 @@ export async function partitionGroups(
|
|
|
135
136
|
);
|
|
136
137
|
|
|
137
138
|
const response = await client.complete(prompt.system, prompt.user);
|
|
138
|
-
|
|
139
|
+
try {
|
|
140
|
+
return parsePartitionResponse(response.content, report, groups);
|
|
141
|
+
} catch {
|
|
142
|
+
const ownership = new Map(report.exclusive);
|
|
143
|
+
const fallbackGroup = groups[groups.length - 1]?.name;
|
|
144
|
+
if (fallbackGroup) {
|
|
145
|
+
for (const entry of report.ambiguous) {
|
|
146
|
+
ownership.set(entry.path, fallbackGroup);
|
|
147
|
+
}
|
|
148
|
+
for (const path of report.unassigned) {
|
|
149
|
+
ownership.set(path, fallbackGroup);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
139
152
|
|
|
140
|
-
|
|
153
|
+
const affected = [...report.ambiguous.map((a) => a.path), ...report.unassigned];
|
|
154
|
+
return {
|
|
155
|
+
ownership,
|
|
156
|
+
reattributed: [],
|
|
157
|
+
warnings: [
|
|
158
|
+
`AI partition response could not be parsed; fallback assignment applied${fallbackGroup ? ` to "${fallbackGroup}"` : ""}`,
|
|
159
|
+
],
|
|
160
|
+
structured_warnings: [{
|
|
161
|
+
category: "system",
|
|
162
|
+
severity: "warn",
|
|
163
|
+
title: "AI partition parse failed; fallback assignment applied",
|
|
164
|
+
message: "Response contained non-JSON artifacts; ambiguous/unassigned files were auto-assigned",
|
|
165
|
+
details: affected,
|
|
166
|
+
}],
|
|
167
|
+
};
|
|
168
|
+
}
|
|
141
169
|
}
|
|
142
170
|
|
|
143
171
|
function parsePartitionResponse(
|
|
@@ -145,8 +173,9 @@ function parsePartitionResponse(
|
|
|
145
173
|
report: AmbiguityReport,
|
|
146
174
|
groups: FileGroup[],
|
|
147
175
|
): PartitionResult {
|
|
148
|
-
const
|
|
149
|
-
|
|
176
|
+
const result = safeParseJson(raw);
|
|
177
|
+
if (!result.ok) throw new Error(`Partition parse failed: ${result.error}`);
|
|
178
|
+
const parsed: unknown = result.data;
|
|
150
179
|
|
|
151
180
|
if (!parsed || typeof parsed !== "object") {
|
|
152
181
|
throw new Error("Expected JSON object for partition response");
|
|
@@ -266,8 +295,4 @@ function parsePartitionResponse(
|
|
|
266
295
|
};
|
|
267
296
|
}
|
|
268
297
|
|
|
269
|
-
|
|
270
|
-
const codeBlockMatch = raw.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/);
|
|
271
|
-
if (codeBlockMatch?.[1]) return codeBlockMatch[1].trim();
|
|
272
|
-
return raw.trim();
|
|
273
|
-
}
|
|
298
|
+
|
package/src/stack/pr-title.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { LlmClient } from "../llm/client.ts";
|
|
2
2
|
import type { StackGroup } from "./types.ts";
|
|
3
|
+
import { safeParseJson } from "./json-utils.ts";
|
|
3
4
|
|
|
4
5
|
const MAX_TITLE_LENGTH = 72;
|
|
5
6
|
const KOREAN_NOUN_END_RE = /(추가|개선|수정|정리|분리|통합|구현|적용|도입|구성|지원|처리|보강|최적화|리팩터링|안정화|마이그레이션|업데이트|생성|검증|연동|변경|제거|작성|설정|관리|보호|강화|정의|확장|대응|복구|표시|유지|등록|삭제|작업)$/;
|
|
@@ -98,10 +99,12 @@ export async function generatePrTitles(
|
|
|
98
99
|
): Promise<Map<string, string>> {
|
|
99
100
|
const groupSummaries = groups
|
|
100
101
|
.map((g, i) => [
|
|
101
|
-
`Group ${i + 1}
|
|
102
|
-
`
|
|
103
|
-
`
|
|
104
|
-
`
|
|
102
|
+
`Group ${i + 1}:`,
|
|
103
|
+
` group_id: "${g.id}"`,
|
|
104
|
+
` name: "${g.name}"`,
|
|
105
|
+
` type: ${g.type}`,
|
|
106
|
+
` description: ${g.description}`,
|
|
107
|
+
` files (${g.files.length}): ${g.files.slice(0, 10).join(", ")}${g.files.length > 10 ? ` ... +${g.files.length - 10} more` : ""}`,
|
|
105
108
|
].join("\n"))
|
|
106
109
|
.join("\n\n");
|
|
107
110
|
|
|
@@ -144,33 +147,36 @@ Bad examples:
|
|
|
144
147
|
- "feat: add JWT refresh middleware" (imperative verb)
|
|
145
148
|
- "" (empty)
|
|
146
149
|
|
|
147
|
-
|
|
150
|
+
IMPORTANT: The "group_id" in your response MUST exactly match the group_id value provided for each group. Do not use the group name or any other value.
|
|
151
|
+
|
|
152
|
+
Return ONLY a JSON array: [{"group_id": "exact group_id from input", "title": "type: descriptive noun phrase"}]`;
|
|
148
153
|
|
|
149
154
|
const user = `Original PR: "${prTitle}"
|
|
150
155
|
|
|
151
156
|
${groupSummaries}
|
|
152
157
|
|
|
153
|
-
Generate a descriptive PR title (40-72 chars) for
|
|
154
|
-
[{"group_id": "...", "title": "..."}]`;
|
|
158
|
+
Generate a unique, descriptive PR title (40-72 chars) for EACH group above. The group_id in your output must exactly match the group_id shown for each group.
|
|
159
|
+
Return JSON array: [{"group_id": "...", "title": "..."}]`;
|
|
155
160
|
|
|
156
161
|
const response = await llmClient.complete(system, user);
|
|
157
162
|
|
|
158
163
|
const titles = new Map<string, string>();
|
|
159
164
|
|
|
165
|
+
const nameToId = new Map<string, string>();
|
|
166
|
+
for (const g of groups) {
|
|
167
|
+
nameToId.set(g.name.toLowerCase(), g.id);
|
|
168
|
+
nameToId.set(g.id.toLowerCase(), g.id);
|
|
169
|
+
}
|
|
170
|
+
|
|
160
171
|
try {
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
} catch {
|
|
166
|
-
const arrayMatch = cleaned.match(/\[[\s\S]*\]/);
|
|
167
|
-
if (!arrayMatch) throw new Error("No JSON array found in response");
|
|
168
|
-
parsed = JSON.parse(arrayMatch[0]) as Array<{ group_id: string; title: string }>;
|
|
169
|
-
}
|
|
172
|
+
const result = safeParseJson<Array<{ group_id: string; title: string }>>(response.content);
|
|
173
|
+
if (!result.ok) throw new Error(result.error);
|
|
174
|
+
const parsed = result.data;
|
|
175
|
+
if (!Array.isArray(parsed)) throw new Error("Expected JSON array");
|
|
170
176
|
for (const item of parsed) {
|
|
171
|
-
if (item.group_id
|
|
172
|
-
|
|
173
|
-
|
|
177
|
+
if (!item.group_id || !item.title?.trim()) continue;
|
|
178
|
+
const resolvedId = nameToId.get(item.group_id.toLowerCase()) ?? item.group_id;
|
|
179
|
+
titles.set(resolvedId, sanitizeTitle(item.title, lang));
|
|
174
180
|
}
|
|
175
181
|
} catch {
|
|
176
182
|
for (const g of groups) {
|