@oh-my-pi/pi-coding-agent 8.0.16 → 8.1.0

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.
Files changed (166) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/package.json +14 -11
  3. package/scripts/generate-wasm-b64.ts +24 -0
  4. package/src/capability/context-file.ts +1 -1
  5. package/src/capability/extension-module.ts +1 -1
  6. package/src/capability/extension.ts +1 -1
  7. package/src/capability/hook.ts +1 -1
  8. package/src/capability/instruction.ts +1 -1
  9. package/src/capability/mcp.ts +1 -1
  10. package/src/capability/prompt.ts +1 -1
  11. package/src/capability/rule.ts +1 -1
  12. package/src/capability/settings.ts +1 -1
  13. package/src/capability/skill.ts +1 -1
  14. package/src/capability/slash-command.ts +1 -1
  15. package/src/capability/ssh.ts +1 -1
  16. package/src/capability/system-prompt.ts +1 -1
  17. package/src/capability/tool.ts +1 -1
  18. package/src/cli/args.ts +1 -1
  19. package/src/cli/plugin-cli.ts +1 -5
  20. package/src/commit/agentic/agent.ts +309 -0
  21. package/src/commit/agentic/fallback.ts +96 -0
  22. package/src/commit/agentic/index.ts +359 -0
  23. package/src/commit/agentic/prompts/analyze-file.md +22 -0
  24. package/src/commit/agentic/prompts/session-user.md +26 -0
  25. package/src/commit/agentic/prompts/split-confirm.md +1 -0
  26. package/src/commit/agentic/prompts/system.md +40 -0
  27. package/src/commit/agentic/state.ts +74 -0
  28. package/src/commit/agentic/tools/analyze-file.ts +131 -0
  29. package/src/commit/agentic/tools/git-file-diff.ts +194 -0
  30. package/src/commit/agentic/tools/git-hunk.ts +50 -0
  31. package/src/commit/agentic/tools/git-overview.ts +84 -0
  32. package/src/commit/agentic/tools/index.ts +56 -0
  33. package/src/commit/agentic/tools/propose-changelog.ts +128 -0
  34. package/src/commit/agentic/tools/propose-commit.ts +154 -0
  35. package/src/commit/agentic/tools/recent-commits.ts +81 -0
  36. package/src/commit/agentic/tools/split-commit.ts +284 -0
  37. package/src/commit/agentic/topo-sort.ts +44 -0
  38. package/src/commit/agentic/trivial.ts +51 -0
  39. package/src/commit/agentic/validation.ts +200 -0
  40. package/src/commit/analysis/conventional.ts +169 -0
  41. package/src/commit/analysis/index.ts +4 -0
  42. package/src/commit/analysis/scope.ts +242 -0
  43. package/src/commit/analysis/summary.ts +114 -0
  44. package/src/commit/analysis/validation.ts +66 -0
  45. package/src/commit/changelog/detect.ts +36 -0
  46. package/src/commit/changelog/generate.ts +112 -0
  47. package/src/commit/changelog/index.ts +233 -0
  48. package/src/commit/changelog/parse.ts +44 -0
  49. package/src/commit/cli.ts +93 -0
  50. package/src/commit/git/diff.ts +148 -0
  51. package/src/commit/git/errors.ts +11 -0
  52. package/src/commit/git/index.ts +217 -0
  53. package/src/commit/git/operations.ts +53 -0
  54. package/src/commit/index.ts +5 -0
  55. package/src/commit/map-reduce/.map-phase.ts.kate-swp +0 -0
  56. package/src/commit/map-reduce/index.ts +63 -0
  57. package/src/commit/map-reduce/map-phase.ts +193 -0
  58. package/src/commit/map-reduce/reduce-phase.ts +147 -0
  59. package/src/commit/map-reduce/utils.ts +9 -0
  60. package/src/commit/message.ts +11 -0
  61. package/src/commit/model-selection.ts +84 -0
  62. package/src/commit/pipeline.ts +242 -0
  63. package/src/commit/prompts/analysis-system.md +155 -0
  64. package/src/commit/prompts/analysis-user.md +41 -0
  65. package/src/commit/prompts/changelog-system.md +56 -0
  66. package/src/commit/prompts/changelog-user.md +19 -0
  67. package/src/commit/prompts/file-observer-system.md +26 -0
  68. package/src/commit/prompts/file-observer-user.md +9 -0
  69. package/src/commit/prompts/reduce-system.md +60 -0
  70. package/src/commit/prompts/reduce-user.md +17 -0
  71. package/src/commit/prompts/summary-retry.md +4 -0
  72. package/src/commit/prompts/summary-system.md +52 -0
  73. package/src/commit/prompts/summary-user.md +13 -0
  74. package/src/commit/prompts/types-description.md +2 -0
  75. package/src/commit/types.ts +109 -0
  76. package/src/commit/utils/exclusions.ts +42 -0
  77. package/src/config/file-lock.ts +111 -0
  78. package/src/config/model-registry.ts +16 -7
  79. package/src/config/settings-manager.ts +115 -40
  80. package/src/config.ts +5 -5
  81. package/src/discovery/agents-md.ts +1 -1
  82. package/src/discovery/builtin.ts +1 -1
  83. package/src/discovery/claude.ts +1 -1
  84. package/src/discovery/cline.ts +1 -1
  85. package/src/discovery/codex.ts +1 -1
  86. package/src/discovery/cursor.ts +1 -1
  87. package/src/discovery/gemini.ts +1 -1
  88. package/src/discovery/github.ts +1 -1
  89. package/src/discovery/index.ts +11 -11
  90. package/src/discovery/mcp-json.ts +1 -1
  91. package/src/discovery/ssh.ts +1 -1
  92. package/src/discovery/vscode.ts +1 -1
  93. package/src/discovery/windsurf.ts +1 -1
  94. package/src/extensibility/custom-commands/loader.ts +1 -1
  95. package/src/extensibility/custom-commands/types.ts +1 -1
  96. package/src/extensibility/custom-tools/loader.ts +1 -1
  97. package/src/extensibility/custom-tools/types.ts +1 -1
  98. package/src/extensibility/extensions/loader.ts +1 -1
  99. package/src/extensibility/extensions/types.ts +1 -1
  100. package/src/extensibility/hooks/loader.ts +1 -1
  101. package/src/extensibility/hooks/types.ts +3 -3
  102. package/src/index.ts +10 -10
  103. package/src/ipy/executor.ts +97 -1
  104. package/src/lsp/index.ts +1 -1
  105. package/src/lsp/render.ts +90 -46
  106. package/src/main.ts +16 -3
  107. package/src/mcp/loader.ts +3 -3
  108. package/src/migrations.ts +3 -3
  109. package/src/modes/components/assistant-message.ts +29 -1
  110. package/src/modes/components/tool-execution.ts +5 -3
  111. package/src/modes/components/tree-selector.ts +1 -1
  112. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  113. package/src/modes/controllers/selector-controller.ts +1 -1
  114. package/src/modes/interactive-mode.ts +5 -3
  115. package/src/modes/rpc/rpc-client.ts +1 -1
  116. package/src/modes/rpc/rpc-mode.ts +1 -4
  117. package/src/modes/rpc/rpc-types.ts +1 -1
  118. package/src/modes/theme/mermaid-cache.ts +89 -0
  119. package/src/modes/theme/theme.ts +2 -0
  120. package/src/modes/types.ts +2 -2
  121. package/src/patch/index.ts +3 -9
  122. package/src/patch/shared.ts +33 -5
  123. package/src/prompts/tools/task.md +2 -0
  124. package/src/sdk.ts +60 -22
  125. package/src/session/agent-session.ts +3 -3
  126. package/src/session/agent-storage.ts +32 -28
  127. package/src/session/artifacts.ts +24 -1
  128. package/src/session/auth-storage.ts +25 -10
  129. package/src/session/storage-migration.ts +12 -53
  130. package/src/system-prompt.ts +2 -2
  131. package/src/task/.executor.ts.kate-swp +0 -0
  132. package/src/task/executor.ts +1 -1
  133. package/src/task/index.ts +10 -1
  134. package/src/task/output-manager.ts +94 -0
  135. package/src/task/render.ts +7 -12
  136. package/src/task/worker.ts +1 -1
  137. package/src/tools/ask.ts +35 -13
  138. package/src/tools/bash.ts +80 -87
  139. package/src/tools/calculator.ts +42 -40
  140. package/src/tools/complete.ts +1 -1
  141. package/src/tools/fetch.ts +67 -104
  142. package/src/tools/find.ts +83 -86
  143. package/src/tools/grep.ts +80 -96
  144. package/src/tools/index.ts +10 -7
  145. package/src/tools/ls.ts +39 -65
  146. package/src/tools/notebook.ts +48 -64
  147. package/src/tools/output-utils.ts +1 -1
  148. package/src/tools/python.ts +71 -183
  149. package/src/tools/read.ts +74 -15
  150. package/src/tools/render-utils.ts +1 -15
  151. package/src/tools/ssh.ts +43 -24
  152. package/src/tools/todo-write.ts +27 -15
  153. package/src/tools/write.ts +93 -64
  154. package/src/tui/code-cell.ts +115 -0
  155. package/src/tui/file-list.ts +48 -0
  156. package/src/tui/index.ts +11 -0
  157. package/src/tui/output-block.ts +73 -0
  158. package/src/tui/status-line.ts +40 -0
  159. package/src/tui/tree-list.ts +56 -0
  160. package/src/tui/types.ts +17 -0
  161. package/src/tui/utils.ts +49 -0
  162. package/src/vendor/photon/photon_rs_bg.wasm.b64.js +1 -0
  163. package/src/web/search/auth.ts +1 -1
  164. package/src/web/search/index.ts +1 -1
  165. package/src/web/search/render.ts +119 -163
  166. package/tsconfig.json +0 -42
@@ -0,0 +1,200 @@
1
+ import { stripTypePrefix } from "@oh-my-pi/pi-coding-agent/commit/analysis/summary";
2
+ import { validateSummary } from "@oh-my-pi/pi-coding-agent/commit/analysis/validation";
3
+ import type { CommitType, ConventionalDetail } from "@oh-my-pi/pi-coding-agent/commit/types";
4
+
5
+ export const SUMMARY_MAX_CHARS = 72;
6
+ export const MAX_DETAIL_ITEMS = 6;
7
+
8
+ const fillerWords = ["comprehensive", "various", "several", "improved", "enhanced", "better"];
9
+ const metaPhrases = ["this commit", "this change", "updated code", "modified files"];
10
+ const pastTenseVerbs = new Set([
11
+ "added",
12
+ "adjusted",
13
+ "aligned",
14
+ "bumped",
15
+ "changed",
16
+ "cleaned",
17
+ "clarified",
18
+ "consolidated",
19
+ "converted",
20
+ "corrected",
21
+ "created",
22
+ "deployed",
23
+ "deprecated",
24
+ "disabled",
25
+ "documented",
26
+ "dropped",
27
+ "enabled",
28
+ "expanded",
29
+ "extracted",
30
+ "fixed",
31
+ "hardened",
32
+ "implemented",
33
+ "improved",
34
+ "integrated",
35
+ "introduced",
36
+ "migrated",
37
+ "moved",
38
+ "optimized",
39
+ "patched",
40
+ "prevented",
41
+ "reduced",
42
+ "refactored",
43
+ "removed",
44
+ "renamed",
45
+ "reorganized",
46
+ "replaced",
47
+ "resolved",
48
+ "restored",
49
+ "restructured",
50
+ "reworked",
51
+ "secured",
52
+ "simplified",
53
+ "stabilized",
54
+ "standardized",
55
+ "streamlined",
56
+ "tightened",
57
+ "tuned",
58
+ "updated",
59
+ "upgraded",
60
+ "validated",
61
+ ]);
62
+ const pastTenseEdExceptions = new Set(["hundred", "red", "bed"]);
63
+
64
+ const unicodeReplacements: Array<[RegExp, string]> = [
65
+ [/[\u2018\u2019]/g, "'"],
66
+ [/[\u201C\u201D]/g, '"'],
67
+ [/[\u2013\u2014\u2212]/g, "-"],
68
+ [/\u2260/g, "!="],
69
+ [/\u00BD/g, "1/2"],
70
+ [/\u03BB/g, "lambda"],
71
+ [/[\u200B-\u200D\uFEFF]/g, ""],
72
+ ];
73
+
74
+ export function normalizeSummary(summary: string, type: CommitType, scope: string | null): string {
75
+ const stripped = stripTypePrefix(summary, type, scope);
76
+ return normalizeUnicode(stripped).replace(/\s+/g, " ").trim();
77
+ }
78
+
79
+ export function normalizeUnicode(text: string): string {
80
+ let result = text;
81
+ for (const [pattern, replacement] of unicodeReplacements) {
82
+ result = result.replace(pattern, replacement);
83
+ }
84
+ return result.normalize("NFC");
85
+ }
86
+
87
+ export function validateSummaryRules(summary: string): { errors: string[]; warnings: string[] } {
88
+ const errors: string[] = [];
89
+ const warnings: string[] = [];
90
+ const basic = validateSummary(summary, SUMMARY_MAX_CHARS);
91
+ if (!basic.valid) {
92
+ errors.push(...basic.errors);
93
+ }
94
+
95
+ const words = summary.trim().split(/\s+/);
96
+ const firstWord = words[0]?.toLowerCase() ?? "";
97
+ const normalizedFirst = firstWord.replace(/[^a-z]/g, "");
98
+ const hasPastTense =
99
+ pastTenseVerbs.has(normalizedFirst) ||
100
+ (normalizedFirst.endsWith("ed") && !pastTenseEdExceptions.has(normalizedFirst));
101
+ if (!hasPastTense) {
102
+ errors.push("Summary must start with a past-tense verb");
103
+ }
104
+
105
+ const lowerSummary = summary.toLowerCase();
106
+ for (const word of fillerWords) {
107
+ if (lowerSummary.includes(word)) {
108
+ warnings.push(`Avoid filler word: ${word}`);
109
+ }
110
+ }
111
+ for (const phrase of metaPhrases) {
112
+ if (lowerSummary.includes(phrase)) {
113
+ warnings.push(`Avoid meta phrase: ${phrase}`);
114
+ }
115
+ }
116
+
117
+ return { errors, warnings };
118
+ }
119
+
120
+ export function capDetails(details: ConventionalDetail[]): { details: ConventionalDetail[]; warnings: string[] } {
121
+ if (details.length <= MAX_DETAIL_ITEMS) {
122
+ return { details, warnings: [] };
123
+ }
124
+
125
+ const scored = details.map((detail, index) => ({
126
+ detail,
127
+ index,
128
+ score: scoreDetail(detail.text),
129
+ }));
130
+
131
+ scored.sort((a, b) => b.score - a.score || a.index - b.index);
132
+ const keep = new Set(scored.slice(0, MAX_DETAIL_ITEMS).map((entry) => entry.index));
133
+ const kept = details.filter((_detail, index) => keep.has(index));
134
+ const warnings = [`Capped detail list to ${MAX_DETAIL_ITEMS} items based on priority scoring.`];
135
+ return { details: kept, warnings };
136
+ }
137
+
138
+ function scoreDetail(text: string): number {
139
+ const lower = text.toLowerCase();
140
+ let score = 0;
141
+ if (/(security|vulnerability|exploit|cve)/.test(lower)) score += 100;
142
+ if (/(breaking|incompatible)/.test(lower)) score += 90;
143
+ if (/(performance|optimization|optimiz|latency|throughput)/.test(lower)) score += 80;
144
+ if (/(bug|fix|crash|panic|regression|failure)/.test(lower)) score += 70;
145
+ if (/(api|interface|public|export)/.test(lower)) score += 50;
146
+ if (/(user|client|customer)/.test(lower)) score += 40;
147
+ if (/(deprecated|removed|delete)/.test(lower)) score += 35;
148
+ return score;
149
+ }
150
+
151
+ export function validateTypeConsistency(
152
+ type: CommitType,
153
+ files: string[],
154
+ options: { diffText?: string; summary?: string; details?: ConventionalDetail[] } = {},
155
+ ): { errors: string[]; warnings: string[] } {
156
+ const errors: string[] = [];
157
+ const warnings: string[] = [];
158
+ const lowerFiles = files.map((file) => file.toLowerCase());
159
+ const hasDocs = lowerFiles.some((file) => /\.(md|mdx|adoc|rst)$/.test(file));
160
+ const hasTests = lowerFiles.some(
161
+ (file) => /(^|\/)(test|tests|__tests__)(\/|$)/.test(file) || /(^|\/).*(_test|\.test|\.spec)\./.test(file),
162
+ );
163
+ const hasCI = lowerFiles.some((file) => file.startsWith(".github/workflows/") || file.startsWith(".gitlab-ci"));
164
+ const hasBuild = lowerFiles.some((file) =>
165
+ ["cargo.toml", "package.json", "makefile"].some((candidate) => file.endsWith(candidate)),
166
+ );
167
+ const hasPerfEvidence = lowerFiles.some((file) => /(bench|benchmark|perf)/.test(file));
168
+ const summary = options.summary?.toLowerCase() ?? "";
169
+ const detailText = options.details?.map((detail) => detail.text.toLowerCase()).join(" ") ?? "";
170
+ const hasPerfKeywords = /(performance|optimiz|latency|throughput|benchmark)/.test(`${summary} ${detailText}`);
171
+
172
+ switch (type) {
173
+ case "docs":
174
+ if (!hasDocs) errors.push("Docs commit should include documentation file changes");
175
+ break;
176
+ case "test":
177
+ if (!hasTests) errors.push("Test commit should include test file changes");
178
+ break;
179
+ case "ci":
180
+ if (!hasCI) errors.push("CI commit should include CI configuration changes");
181
+ break;
182
+ case "build":
183
+ if (!hasBuild) errors.push("Build commit should include build-related files");
184
+ break;
185
+ case "refactor": {
186
+ const hasNewFiles = options.diffText ? /\nnew file mode\s/m.test(options.diffText) : false;
187
+ if (hasNewFiles) warnings.push("Refactor commit adds new files; consider feat if new functionality");
188
+ break;
189
+ }
190
+ case "perf":
191
+ if (!hasPerfEvidence && !hasPerfKeywords) {
192
+ warnings.push("Perf commit lacks benchmark or performance keywords");
193
+ }
194
+ break;
195
+ default:
196
+ break;
197
+ }
198
+
199
+ return { errors, warnings };
200
+ }
@@ -0,0 +1,169 @@
1
+ import type { Api, AssistantMessage, Model, ToolCall } from "@oh-my-pi/pi-ai";
2
+ import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai";
3
+ import analysisSystemPrompt from "@oh-my-pi/pi-coding-agent/commit/prompts/analysis-system.md" with { type: "text" };
4
+ import analysisUserPrompt from "@oh-my-pi/pi-coding-agent/commit/prompts/analysis-user.md" with { type: "text" };
5
+ import type {
6
+ ChangelogCategory,
7
+ ConventionalAnalysis,
8
+ ConventionalDetail,
9
+ } from "@oh-my-pi/pi-coding-agent/commit/types";
10
+ import { renderPromptTemplate } from "@oh-my-pi/pi-coding-agent/config/prompt-templates";
11
+ import { Type } from "@sinclair/typebox";
12
+
13
+ const ConventionalAnalysisTool = {
14
+ name: "create_conventional_analysis",
15
+ description: "Analyze a diff and return conventional commit classification.",
16
+ parameters: Type.Object({
17
+ type: Type.Union([
18
+ Type.Literal("feat"),
19
+ Type.Literal("fix"),
20
+ Type.Literal("refactor"),
21
+ Type.Literal("docs"),
22
+ Type.Literal("test"),
23
+ Type.Literal("chore"),
24
+ Type.Literal("style"),
25
+ Type.Literal("perf"),
26
+ Type.Literal("build"),
27
+ Type.Literal("ci"),
28
+ Type.Literal("revert"),
29
+ ]),
30
+ scope: Type.Union([Type.String(), Type.Null()]),
31
+ details: Type.Array(
32
+ Type.Object({
33
+ text: Type.String(),
34
+ changelog_category: Type.Optional(
35
+ Type.Union([
36
+ Type.Literal("Added"),
37
+ Type.Literal("Changed"),
38
+ Type.Literal("Fixed"),
39
+ Type.Literal("Deprecated"),
40
+ Type.Literal("Removed"),
41
+ Type.Literal("Security"),
42
+ Type.Literal("Breaking Changes"),
43
+ ]),
44
+ ),
45
+ user_visible: Type.Optional(Type.Boolean()),
46
+ }),
47
+ ),
48
+ issue_refs: Type.Array(Type.String()),
49
+ }),
50
+ };
51
+
52
+ export interface ConventionalAnalysisInput {
53
+ model: Model<Api>;
54
+ apiKey: string;
55
+ contextFiles?: Array<{ path: string; content: string }>;
56
+ userContext?: string;
57
+ typesDescription?: string;
58
+ recentCommits?: string[];
59
+ scopeCandidates: string;
60
+ stat: string;
61
+ diff: string;
62
+ }
63
+
64
+ /**
65
+ * Generate conventional analysis data from a diff and metadata.
66
+ */
67
+ export async function generateConventionalAnalysis({
68
+ model,
69
+ apiKey,
70
+ contextFiles,
71
+ userContext,
72
+ typesDescription,
73
+ recentCommits,
74
+ scopeCandidates,
75
+ stat,
76
+ diff,
77
+ }: ConventionalAnalysisInput): Promise<ConventionalAnalysis> {
78
+ const prompt = renderPromptTemplate(analysisUserPrompt, {
79
+ context_files: contextFiles && contextFiles.length > 0 ? contextFiles : undefined,
80
+ user_context: userContext,
81
+ types_description: typesDescription,
82
+ recent_commits: recentCommits?.join("\n"),
83
+ scope_candidates: scopeCandidates,
84
+ stat,
85
+ diff,
86
+ });
87
+
88
+ const response = await completeSimple(
89
+ model,
90
+ {
91
+ systemPrompt: renderPromptTemplate(analysisSystemPrompt),
92
+ messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
93
+ tools: [ConventionalAnalysisTool],
94
+ },
95
+ { apiKey, maxTokens: 2400 },
96
+ );
97
+
98
+ return parseAnalysisFromResponse(response);
99
+ }
100
+
101
+ function parseAnalysisFromResponse(message: AssistantMessage): ConventionalAnalysis {
102
+ const toolCall = extractToolCall(message, "create_conventional_analysis");
103
+ if (toolCall) {
104
+ const parsed = validateToolCall([ConventionalAnalysisTool], toolCall) as {
105
+ type: ConventionalAnalysis["type"];
106
+ scope: string | null;
107
+ details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>;
108
+ issue_refs: string[];
109
+ };
110
+ return normalizeAnalysis(parsed);
111
+ }
112
+
113
+ const text = extractTextContent(message);
114
+ const parsed = parseJsonPayload(text) as {
115
+ type: ConventionalAnalysis["type"];
116
+ scope: string | null;
117
+ details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>;
118
+ issue_refs: string[];
119
+ };
120
+ return normalizeAnalysis(parsed);
121
+ }
122
+
123
+ function normalizeAnalysis(parsed: {
124
+ type: ConventionalAnalysis["type"];
125
+ scope: string | null;
126
+ details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>;
127
+ issue_refs: string[];
128
+ }): ConventionalAnalysis {
129
+ const details: ConventionalDetail[] = parsed.details.map((detail) => ({
130
+ text: detail.text.trim(),
131
+ changelogCategory: detail.user_visible ? detail.changelog_category : undefined,
132
+ userVisible: detail.user_visible ?? false,
133
+ }));
134
+ return {
135
+ type: parsed.type,
136
+ scope: parsed.scope?.trim() || null,
137
+ details,
138
+ issueRefs: parsed.issue_refs ?? [],
139
+ };
140
+ }
141
+
142
+ function extractToolCall(message: AssistantMessage, name: string): ToolCall | undefined {
143
+ for (const content of message.content) {
144
+ if (content.type === "toolCall" && content.name === name) {
145
+ return content;
146
+ }
147
+ }
148
+ return undefined;
149
+ }
150
+
151
+ function extractTextContent(message: AssistantMessage): string {
152
+ return message.content
153
+ .filter((content) => content.type === "text")
154
+ .map((content) => content.text)
155
+ .join("")
156
+ .trim();
157
+ }
158
+
159
+ function parseJsonPayload(text: string): unknown {
160
+ const trimmed = text.trim();
161
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
162
+ return JSON.parse(trimmed) as unknown;
163
+ }
164
+ const match = trimmed.match(/\{[\s\S]*\}/);
165
+ if (!match) {
166
+ throw new Error("No JSON payload found in analysis response");
167
+ }
168
+ return JSON.parse(match[0]) as unknown;
169
+ }
@@ -0,0 +1,4 @@
1
+ export { generateConventionalAnalysis } from "@oh-my-pi/pi-coding-agent/commit/analysis/conventional";
2
+ export { extractScopeCandidates } from "@oh-my-pi/pi-coding-agent/commit/analysis/scope";
3
+ export { generateSummary, stripTypePrefix } from "@oh-my-pi/pi-coding-agent/commit/analysis/summary";
4
+ export { validateAnalysis, validateScope, validateSummary } from "@oh-my-pi/pi-coding-agent/commit/analysis/validation";
@@ -0,0 +1,242 @@
1
+ import type { NumstatEntry } from "@oh-my-pi/pi-coding-agent/commit/types";
2
+ import { isExcludedFile } from "@oh-my-pi/pi-coding-agent/commit/utils/exclusions";
3
+
4
+ interface ScopeCandidate {
5
+ path: string;
6
+ percentage: number;
7
+ confidence: number;
8
+ }
9
+
10
+ const PLACEHOLDER_DIRS = new Set([
11
+ "src",
12
+ "lib",
13
+ "bin",
14
+ "crates",
15
+ "benches",
16
+ "examples",
17
+ "internal",
18
+ "pkg",
19
+ "include",
20
+ "tests",
21
+ "test",
22
+ "docs",
23
+ "packages",
24
+ "modules",
25
+ ]);
26
+
27
+ const SKIP_DIRS = new Set(["test", "tests", "benches", "examples", "target", "build", "node_modules", ".github"]);
28
+
29
+ export interface ScopeCandidatesResult {
30
+ scopeCandidates: string;
31
+ isWide: boolean;
32
+ }
33
+
34
+ export function extractScopeCandidates(numstat: NumstatEntry[]): ScopeCandidatesResult {
35
+ const componentLines = new Map<string, number>();
36
+ const paths: string[] = [];
37
+ const distinctRoots = new Set<string>();
38
+ let totalLines = 0;
39
+
40
+ for (const entry of numstat) {
41
+ const linesChanged = entry.additions + entry.deletions;
42
+ if (linesChanged === 0) continue;
43
+ const normalizedPath = normalizePathForScope(entry.path);
44
+ if (isExcludedFile(normalizedPath)) continue;
45
+ paths.push(normalizedPath);
46
+ const root = extractTopLevelRoot(normalizedPath);
47
+ if (root) {
48
+ distinctRoots.add(root);
49
+ }
50
+ totalLines += linesChanged;
51
+ const components = extractComponentsFromPath(normalizedPath);
52
+ for (const component of components) {
53
+ if (component.split("/").some((segment) => segment.includes("."))) {
54
+ continue;
55
+ }
56
+ componentLines.set(component, (componentLines.get(component) ?? 0) + linesChanged);
57
+ }
58
+ }
59
+
60
+ if (totalLines === 0) {
61
+ return { scopeCandidates: "(none - no measurable changes)", isWide: false };
62
+ }
63
+
64
+ const candidates = buildScopeCandidates(componentLines, totalLines);
65
+ const isWide = isWideChange(candidates, 0.6, distinctRoots.size);
66
+ if (isWide) {
67
+ const pattern = analyzeWideChange(paths);
68
+ if (pattern) {
69
+ return { scopeCandidates: `(cross-cutting: ${pattern})`, isWide: true };
70
+ }
71
+ return { scopeCandidates: "(none - multi-component change)", isWide: true };
72
+ }
73
+
74
+ const suggestionParts: string[] = [];
75
+ for (const candidate of candidates.slice(0, 5)) {
76
+ if (candidate.percentage < 10) continue;
77
+ const confidenceLabel = candidate.path.includes("/")
78
+ ? candidate.percentage > 60
79
+ ? "high confidence"
80
+ : "moderate confidence"
81
+ : "high confidence";
82
+ suggestionParts.push(`${candidate.path} (${candidate.percentage.toFixed(0)}%, ${confidenceLabel})`);
83
+ }
84
+
85
+ const scopeCandidates =
86
+ suggestionParts.length === 0
87
+ ? "(none - unclear component)"
88
+ : `${suggestionParts.join(", ")}\nPrefer 2-segment scopes marked 'high confidence'`;
89
+
90
+ return { scopeCandidates, isWide: false };
91
+ }
92
+
93
+ function buildScopeCandidates(componentLines: Map<string, number>, totalLines: number): ScopeCandidate[] {
94
+ const candidates: ScopeCandidate[] = [];
95
+ for (const [path, lines] of componentLines.entries()) {
96
+ if (!path.includes("/") && PLACEHOLDER_DIRS.has(path)) continue;
97
+ const root = path.split("/")[0] ?? "";
98
+ if (PLACEHOLDER_DIRS.has(root)) continue;
99
+ const percentage = (lines / totalLines) * 100;
100
+ const isTwoSegment = path.includes("/");
101
+ const confidence = isTwoSegment ? (percentage > 60 ? percentage * 1.2 : percentage * 0.8) : percentage;
102
+ candidates.push({ path, percentage, confidence });
103
+ }
104
+ return candidates.sort((a, b) => b.confidence - a.confidence);
105
+ }
106
+
107
+ function isWideChange(candidates: ScopeCandidate[], threshold: number, distinctRoots: number): boolean {
108
+ if (distinctRoots >= 3) return true;
109
+ const top = candidates[0];
110
+ if (!top) return false;
111
+ return top.percentage / 100 < threshold;
112
+ }
113
+
114
+ function extractComponentsFromPath(path: string): string[] {
115
+ const segments = path.split("/");
116
+ const meaningful: string[] = [];
117
+
118
+ const stripExt = (segment: string): string => {
119
+ const index = segment.lastIndexOf(".");
120
+ return index > 0 ? segment.slice(0, index) : segment;
121
+ };
122
+
123
+ const isFile = (segment: string): boolean => {
124
+ return segment.includes(".") && !segment.startsWith(".") && segment.lastIndexOf(".") > 0;
125
+ };
126
+
127
+ for (let index = 0; index < segments.length; index += 1) {
128
+ const segment = segments[index] ?? "";
129
+ if (PLACEHOLDER_DIRS.has(segment) && segments.length > index + 1) {
130
+ continue;
131
+ }
132
+ if (isFile(segment)) continue;
133
+ if (SKIP_DIRS.has(segment)) continue;
134
+
135
+ const stripped = stripExt(segment);
136
+ if (stripped && !stripped.startsWith(".")) {
137
+ meaningful.push(stripped);
138
+ }
139
+ }
140
+
141
+ const components: string[] = [];
142
+ if (meaningful.length > 0) {
143
+ components.push(meaningful[0]!);
144
+ if (meaningful.length >= 2) {
145
+ components.push(`${meaningful[0]}/${meaningful[1]}`);
146
+ }
147
+ }
148
+
149
+ return components;
150
+ }
151
+
152
+ function extractTopLevelRoot(path: string): string | null {
153
+ const segments = path.split("/").filter((segment) => segment.length > 0);
154
+ if (segments.length === 0) return null;
155
+ if (segments.length === 1) {
156
+ return segments[0]!.startsWith(".") ? null : "(root)";
157
+ }
158
+
159
+ for (let index = 0; index < segments.length; index += 1) {
160
+ const segment = segments[index] ?? "";
161
+ if (PLACEHOLDER_DIRS.has(segment) && segments.length > index + 1) {
162
+ continue;
163
+ }
164
+ if (SKIP_DIRS.has(segment)) continue;
165
+ if (segment.startsWith(".")) continue;
166
+ return segment;
167
+ }
168
+
169
+ return null;
170
+ }
171
+
172
+ function normalizePathForScope(path: string): string {
173
+ const braceStart = path.indexOf("{");
174
+ if (braceStart !== -1) {
175
+ const arrowPos = path.indexOf(" => ", braceStart);
176
+ if (arrowPos !== -1) {
177
+ const braceEnd = path.indexOf("}", arrowPos);
178
+ if (braceEnd !== -1) {
179
+ const prefix = path.slice(0, braceStart);
180
+ const newName = path.slice(arrowPos + 4, braceEnd).trim();
181
+ return `${prefix}${newName}`;
182
+ }
183
+ }
184
+ }
185
+
186
+ if (path.includes(" => ")) {
187
+ const parts = path.split(" => ");
188
+ return parts[1]?.trim() ?? path.trim();
189
+ }
190
+
191
+ return path.trim();
192
+ }
193
+
194
+ function analyzeWideChange(paths: string[]): string | null {
195
+ if (paths.length === 0) return null;
196
+ const total = paths.length;
197
+ let mdCount = 0;
198
+ let testCount = 0;
199
+ let configCount = 0;
200
+ let hasCargoToml = false;
201
+ let hasPackageJson = false;
202
+ let errorKeywords = 0;
203
+ let typeKeywords = 0;
204
+
205
+ for (const path of paths) {
206
+ const lowerPath = path.toLowerCase();
207
+ if (lowerPath.endsWith(".md")) {
208
+ mdCount += 1;
209
+ }
210
+ if (lowerPath.includes("/test") || lowerPath.includes("_test.")) {
211
+ testCount += 1;
212
+ }
213
+ if (
214
+ lowerPath.endsWith(".toml") ||
215
+ lowerPath.endsWith(".yaml") ||
216
+ lowerPath.endsWith(".yml") ||
217
+ lowerPath.endsWith(".json")
218
+ ) {
219
+ configCount += 1;
220
+ }
221
+ if (path.includes("Cargo.toml")) {
222
+ hasCargoToml = true;
223
+ }
224
+ if (path.includes("package.json")) {
225
+ hasPackageJson = true;
226
+ }
227
+ if (lowerPath.includes("error") || lowerPath.includes("result") || lowerPath.includes("err")) {
228
+ errorKeywords += 1;
229
+ }
230
+ if (lowerPath.includes("type") || lowerPath.includes("struct") || lowerPath.includes("enum")) {
231
+ typeKeywords += 1;
232
+ }
233
+ }
234
+
235
+ if (hasCargoToml || hasPackageJson) return "deps";
236
+ if ((mdCount * 100) / total > 70) return "docs";
237
+ if ((testCount * 100) / total > 60) return "tests";
238
+ if ((errorKeywords * 100) / total > 40) return "error-handling";
239
+ if ((typeKeywords * 100) / total > 40) return "type-refactor";
240
+ if ((configCount * 100) / total > 50) return "config";
241
+ return null;
242
+ }