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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "newpr",
3
- "version": "1.0.17",
3
+ "version": "1.0.18",
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");
@@ -266,8 +295,4 @@ function parsePartitionResponse(
266
295
  };
267
296
  }
268
297
 
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
- }
298
+
@@ -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) {