newpr 1.0.17 → 1.0.19

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "newpr",
3
- "version": "1.0.17",
3
+ "version": "1.0.19",
4
4
  "description": "AI-powered large PR review tool - understand PRs with 1000+ lines of changes",
5
5
  "module": "src/cli/index.ts",
6
6
  "type": "module",
@@ -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
+ }
@@ -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 cleaned = response.content.replace(/```(?:json)?\s*/g, "").replace(/```\s*/g, "").trim();
94
- const parsed = JSON.parse(cleaned) as { moves: Array<{ path: string; to: string; reason: string }> };
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
+ }
@@ -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
- const parsed = parsePartitionResponse(response.content, report, groups);
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
- return parsed;
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 jsonStr = extractJson(raw);
149
- const parsed: unknown = JSON.parse(jsonStr);
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");
@@ -164,7 +193,25 @@ function parsePartitionResponse(
164
193
  const warnings: string[] = [];
165
194
  const structuredWarnings: StackWarning[] = [];
166
195
 
167
- const validGroupNames = new Set(groups.map((g) => g.name));
196
+ let sharedFoundation: FileGroup | undefined;
197
+ if (data.shared_foundation && typeof data.shared_foundation === "object") {
198
+ const sf = data.shared_foundation as Record<string, unknown>;
199
+ sharedFoundation = {
200
+ name: String(sf.name ?? "Shared Foundation"),
201
+ type: "chore",
202
+ description: String(sf.description ?? "Common infrastructure changes"),
203
+ files: Array.isArray(sf.files) ? sf.files.map(String) : [],
204
+ };
205
+ }
206
+
207
+ const groupNameLookup = new Map<string, string>();
208
+ for (const group of groups) {
209
+ groupNameLookup.set(group.name.toLowerCase(), group.name);
210
+ }
211
+ if (sharedFoundation) {
212
+ groupNameLookup.set(sharedFoundation.name.toLowerCase(), sharedFoundation.name);
213
+ groupNameLookup.set("shared foundation", sharedFoundation.name);
214
+ }
168
215
 
169
216
  for (const item of assignments) {
170
217
  const entry = item as Record<string, unknown>;
@@ -177,7 +224,26 @@ function parsePartitionResponse(
177
224
  continue;
178
225
  }
179
226
 
180
- if (!validGroupNames.has(group)) {
227
+ const normalizedGroup = group.toLowerCase().replace(/["'`]/g, "").trim();
228
+ const isSharedFoundationAlias = /shared[\s_-]*foundation/.test(normalizedGroup);
229
+ let canonicalGroup = groupNameLookup.get(normalizedGroup);
230
+ if (!canonicalGroup && isSharedFoundationAlias) {
231
+ if (!sharedFoundation) {
232
+ sharedFoundation = {
233
+ name: "Shared Foundation",
234
+ type: "chore",
235
+ description: "Common infrastructure changes",
236
+ files: [],
237
+ };
238
+ }
239
+ groupNameLookup.set(sharedFoundation.name.toLowerCase(), sharedFoundation.name);
240
+ groupNameLookup.set("shared-foundation", sharedFoundation.name);
241
+ groupNameLookup.set("shared_foundation", sharedFoundation.name);
242
+ groupNameLookup.set("shared foundation", sharedFoundation.name);
243
+ canonicalGroup = sharedFoundation.name;
244
+ }
245
+
246
+ if (!canonicalGroup) {
181
247
  warnings.push(`Unknown group "${group}" for file "${path}", skipping`);
182
248
  structuredWarnings.push({
183
249
  category: "system",
@@ -195,19 +261,22 @@ function parsePartitionResponse(
195
261
  reattributed.push({
196
262
  path,
197
263
  from_groups: ambiguousEntry.groups,
198
- to_group: group,
264
+ to_group: canonicalGroup,
199
265
  reason,
200
266
  });
201
267
  } else if (isUnassigned) {
202
268
  reattributed.push({
203
269
  path,
204
270
  from_groups: [],
205
- to_group: group,
271
+ to_group: canonicalGroup,
206
272
  reason,
207
273
  });
208
274
  }
209
275
 
210
- ownership.set(path, group);
276
+ ownership.set(path, canonicalGroup);
277
+ if (sharedFoundation && canonicalGroup === sharedFoundation.name && !sharedFoundation.files.includes(path)) {
278
+ sharedFoundation.files.push(path);
279
+ }
211
280
  }
212
281
 
213
282
  const stillUnassigned = report.unassigned.filter((p) => !ownership.has(p));
@@ -245,18 +314,6 @@ function parsePartitionResponse(
245
314
  });
246
315
  }
247
316
 
248
- let sharedFoundation: FileGroup | undefined;
249
- if (data.shared_foundation && typeof data.shared_foundation === "object") {
250
- const sf = data.shared_foundation as Record<string, unknown>;
251
- sharedFoundation = {
252
- name: String(sf.name ?? "Shared Foundation"),
253
- type: "chore",
254
- description: String(sf.description ?? "Common infrastructure changes"),
255
- files: Array.isArray(sf.files) ? sf.files.map(String) : [],
256
- };
257
- validGroupNames.add(sharedFoundation.name);
258
- }
259
-
260
317
  return {
261
318
  ownership,
262
319
  reattributed,
@@ -265,9 +322,3 @@ function parsePartitionResponse(
265
322
  structured_warnings: structuredWarnings,
266
323
  };
267
324
  }
268
-
269
- function extractJson(raw: string): string {
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
- }
@@ -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}: "${g.name}"`,
102
- ` Type: ${g.type}`,
103
- ` Description: ${g.description}`,
104
- ` Files (${g.files.length}): ${g.files.slice(0, 10).join(", ")}${g.files.length > 10 ? ` ... +${g.files.length - 10} more` : ""}`,
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
- Return ONLY a JSON array: [{"group_id": "...", "title": "..."}]`;
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 each group. Return JSON array:
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 cleaned = response.content.replace(/```(?:json)?\s*/g, "").replace(/```\s*/g, "").trim();
162
- let parsed: Array<{ group_id: string; title: string }> = [];
163
- try {
164
- parsed = JSON.parse(cleaned) as Array<{ group_id: string; title: string }>;
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 && item.title?.trim()) {
172
- titles.set(item.group_id, sanitizeTitle(item.title, lang));
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) {