pi-ui-extend 0.1.36 → 0.1.37

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 (70) hide show
  1. package/dist/app/app.d.ts +7 -0
  2. package/dist/app/app.js +40 -5
  3. package/dist/app/commands/command-controller.js +1 -0
  4. package/dist/app/commands/command-registry.d.ts +1 -0
  5. package/dist/app/commands/command-registry.js +8 -0
  6. package/dist/app/commands/command-session-actions.d.ts +2 -0
  7. package/dist/app/commands/command-session-actions.js +79 -1
  8. package/dist/app/extensions/extension-actions-controller.d.ts +4 -1
  9. package/dist/app/extensions/extension-actions-controller.js +31 -2
  10. package/dist/app/input/input-controller.d.ts +1 -0
  11. package/dist/app/input/input-controller.js +23 -2
  12. package/dist/app/input/terminal-edit-shortcuts.d.ts +1 -0
  13. package/dist/app/input/terminal-edit-shortcuts.js +7 -0
  14. package/dist/app/input/voice-controller.js +1 -1
  15. package/dist/app/popup/popup-action-controller.d.ts +1 -3
  16. package/dist/app/popup/popup-action-controller.js +1 -5
  17. package/dist/app/rendering/message-content.js +4 -3
  18. package/dist/app/rendering/render-controller.js +21 -38
  19. package/dist/app/rendering/status-line-renderer.d.ts +1 -0
  20. package/dist/app/rendering/status-line-renderer.js +14 -2
  21. package/dist/app/runtime.js +12 -2
  22. package/dist/app/screen/mouse-controller.js +2 -0
  23. package/dist/app/session/session-event-controller.d.ts +7 -0
  24. package/dist/app/session/session-event-controller.js +10 -13
  25. package/dist/app/terminal/terminal-controller.js +1 -0
  26. package/dist/app/terminal/terminal-output-buffer.d.ts +8 -6
  27. package/dist/app/terminal/terminal-output-buffer.js +24 -16
  28. package/dist/bundled-extensions/terminal-bell/index.js +118 -33
  29. package/dist/markdown-format.d.ts +1 -0
  30. package/dist/markdown-format.js +30 -16
  31. package/dist/schemas/pi-tools-suite-schema.d.ts +5 -0
  32. package/dist/schemas/pi-tools-suite-schema.js +5 -0
  33. package/dist/tool-renderers/apply-patch.js +6 -1
  34. package/dist/tool-renderers/patch-normalize.d.ts +24 -0
  35. package/dist/tool-renderers/patch-normalize.js +163 -0
  36. package/external/pi-tools-suite/README.md +3 -2
  37. package/external/pi-tools-suite/package.json +3 -3
  38. package/external/pi-tools-suite/src/antigravity-auth/index.ts +15 -2
  39. package/external/pi-tools-suite/src/antigravity-auth/status.ts +36 -19
  40. package/external/pi-tools-suite/src/async-subagents/async-subagents.sample.jsonc +5 -2
  41. package/external/pi-tools-suite/src/async-subagents/commands.ts +12 -2
  42. package/external/pi-tools-suite/src/async-subagents/core/config.ts +8 -3
  43. package/external/pi-tools-suite/src/async-subagents/core/routing.ts +63 -28
  44. package/external/pi-tools-suite/src/async-subagents/core/tool-guard.ts +9 -4
  45. package/external/pi-tools-suite/src/comment-checker/config.ts +98 -0
  46. package/external/pi-tools-suite/src/comment-checker/detect.ts +215 -0
  47. package/external/pi-tools-suite/src/comment-checker/index.ts +294 -0
  48. package/external/pi-tools-suite/src/dcp/commands.ts +29 -15
  49. package/external/pi-tools-suite/src/dcp/compress-tool.ts +111 -60
  50. package/external/pi-tools-suite/src/dcp/config.ts +10 -6
  51. package/external/pi-tools-suite/src/dcp/debug-log.ts +235 -0
  52. package/external/pi-tools-suite/src/dcp/index.ts +204 -27
  53. package/external/pi-tools-suite/src/dcp/prompts.ts +25 -28
  54. package/external/pi-tools-suite/src/dcp/pruner-candidates.ts +6 -10
  55. package/external/pi-tools-suite/src/dcp/pruner-compression-blocks.ts +19 -1
  56. package/external/pi-tools-suite/src/dcp/pruner-message-ids.ts +36 -58
  57. package/external/pi-tools-suite/src/dcp/pruner-metadata.ts +18 -0
  58. package/external/pi-tools-suite/src/dcp/pruner-nudge.ts +3 -3
  59. package/external/pi-tools-suite/src/dcp/pruner.ts +4 -2
  60. package/external/pi-tools-suite/src/dcp/state-persistence.ts +31 -2
  61. package/external/pi-tools-suite/src/dcp/state.ts +62 -4
  62. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +18 -0
  63. package/external/pi-tools-suite/src/index.ts +1 -0
  64. package/external/pi-tools-suite/src/model-tools/index.ts +11 -3
  65. package/external/pi-tools-suite/src/telegram-mirror/index.ts +1 -1
  66. package/external/pi-tools-suite/src/todo/index.ts +24 -0
  67. package/external/pi-tools-suite/src/tool-descriptions.ts +3 -3
  68. package/external/pi-tools-suite/src/usage/index.ts +18 -4
  69. package/package.json +4 -4
  70. package/schemas/pi-tools-suite.json +24 -0
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { ignoreStaleExtensionContextError } from "../context-usage";
2
3
  import { decodeApiKey, getEffectiveProjectId } from "./auth-store";
3
4
  import { formatAddAccountResult, parseAddAccountCommandArgs } from "./commands";
4
5
  import { API_ID, DEFAULT_PROJECT_ID, ENDPOINT_DAILY, PROVIDER_ID } from "./constants";
@@ -10,6 +11,14 @@ import { streamAntigravity } from "./stream";
10
11
  export { addAntigravityAccount } from "./oauth";
11
12
  export type { AntigravityAddAccountResult } from "./types";
12
13
 
14
+ async function staleSafe(action: () => void | Promise<void>): Promise<void> {
15
+ try {
16
+ await action();
17
+ } catch (error) {
18
+ ignoreStaleExtensionContextError(error);
19
+ }
20
+ }
21
+
13
22
  export default async function antigravityAuth(pi: ExtensionAPI): Promise<void> {
14
23
  rememberAntigravityApi(pi);
15
24
  await publishAntigravityAuthStartupSection();
@@ -40,7 +49,9 @@ export default async function antigravityAuth(pi: ExtensionAPI): Promise<void> {
40
49
  {
41
50
  onAuth: ({ url }) => {
42
51
  authUrl = url;
43
- ctx.ui?.notify?.(`Open this Antigravity OAuth URL, then paste the callback URL:\n${url}`, "info");
52
+ void staleSafe(() => {
53
+ ctx.ui?.notify?.(`Open this Antigravity OAuth URL, then paste the callback URL:\n${url}`, "info");
54
+ });
44
55
  },
45
56
  onPrompt: async ({ message }) => {
46
57
  if (typeof ctx.ui?.input !== "function") {
@@ -53,7 +64,9 @@ export default async function antigravityAuth(pi: ExtensionAPI): Promise<void> {
53
64
  },
54
65
  options,
55
66
  );
56
- ctx.ui?.notify?.(formatAddAccountResult(result), "info");
67
+ await staleSafe(() => {
68
+ ctx.ui?.notify?.(formatAddAccountResult(result), "info");
69
+ });
57
70
  emitAntigravityStatus(await getCurrentAntigravityStatus());
58
71
  } catch (error) {
59
72
  notifyAntigravityLoginFailure(error);
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
+ import { ignoreStaleExtensionContextError } from "../context-usage";
2
3
  import { publishStartupSection } from "../startup-section";
3
4
  import { LEGACY_STATUS_KEY, PROVIDER_ID, STATUS_KEY } from "./constants";
4
5
  import { accountFromCredential, clampAccountIndex, decodeApiKey, getAccountProjectId, getEffectiveProjectId, getPiAuthPath, getStoredAccounts, readJsonFile } from "./auth-store";
@@ -23,6 +24,14 @@ function errorMessage(error: unknown): string {
23
24
  return error instanceof Error ? error.message : String(error);
24
25
  }
25
26
 
27
+ function staleSafe(action: () => void): void {
28
+ try {
29
+ action();
30
+ } catch (error) {
31
+ ignoreStaleExtensionContextError(error);
32
+ }
33
+ }
34
+
26
35
  export function formatAntigravityLoginFailure(error: unknown): string {
27
36
  return `Antigravity login failed: ${errorMessage(error)}. Auth file: ${getPiAuthPath()}`;
28
37
  }
@@ -34,18 +43,22 @@ export function formatAntigravityProviderFailure(error: unknown): string {
34
43
  function notifyAntigravityFailure(message: string, details: Record<string, unknown>, options: { ui?: ExtensionUIContext; sendSessionMessage?: boolean } = {}): void {
35
44
  const { ui, sendSessionMessage = true } = options;
36
45
  const targetUi = ui ?? extensionUi;
37
- if (typeof targetUi?.notify === "function") {
38
- targetUi.notify(message, "error");
39
- } else if (typeof (targetUi as any)?.toast?.error === "function") {
40
- (targetUi as any).toast.error(message);
41
- }
46
+ staleSafe(() => {
47
+ if (typeof targetUi?.notify === "function") {
48
+ targetUi.notify(message, "error");
49
+ } else if (typeof (targetUi as any)?.toast?.error === "function") {
50
+ (targetUi as any).toast.error(message);
51
+ }
52
+ });
42
53
  if (!sendSessionMessage) return;
43
- (extensionApi as any)?.sendMessage?.({
44
- customType: "antigravity-auth-status",
45
- content: message,
46
- display: true,
47
- details,
48
- }, { triggerTurn: false });
54
+ staleSafe(() => {
55
+ (extensionApi as any)?.sendMessage?.({
56
+ customType: "antigravity-auth-status",
57
+ content: message,
58
+ display: true,
59
+ details,
60
+ }, { triggerTurn: false });
61
+ });
49
62
  }
50
63
 
51
64
  function shouldNotifyProviderFailure(message: string, model?: string): boolean {
@@ -145,13 +158,17 @@ export async function publishAntigravityAuthStartupSection(): Promise<void> {
145
158
  }
146
159
 
147
160
  export function emitAntigravityStatus(details: AntigravityStatusDetails): void {
148
- if (typeof extensionUi?.setStatus === "function") {
149
- extensionUi.setStatus(LEGACY_STATUS_KEY, undefined);
150
- extensionUi.setStatus(STATUS_KEY, formatAntigravityStatus(details));
151
- }
152
- (extensionApi as any)?.sendMessage?.({
153
- role: "system",
154
- content: formatAntigravityStatus(details),
155
- details,
161
+ staleSafe(() => {
162
+ if (typeof extensionUi?.setStatus === "function") {
163
+ extensionUi.setStatus(LEGACY_STATUS_KEY, undefined);
164
+ extensionUi.setStatus(STATUS_KEY, formatAntigravityStatus(details));
165
+ }
166
+ });
167
+ staleSafe(() => {
168
+ (extensionApi as any)?.sendMessage?.({
169
+ role: "system",
170
+ content: formatAntigravityStatus(details),
171
+ details,
172
+ });
156
173
  });
157
174
  }
@@ -55,10 +55,13 @@
55
55
  "routing": {
56
56
  "enabled": true,
57
57
  "model": "zai/glm-4.5-air",
58
+ // Ordered router model fallbacks tried when the primary routing model is
59
+ // unavailable or fails. The current parent model is always tried last.
60
+ "fallbackModels": ["openai-codex/gpt-5.3-codex-spark"],
58
61
  "maxTaskChars": 1200,
59
62
  "maxTokens": 512,
60
- "maxRetries": 1,
61
- "timeoutMs": 12000,
63
+ "maxRetries": 3,
64
+ "timeoutMs": 10000,
62
65
  "debug": false
63
66
  },
64
67
 
@@ -415,7 +415,12 @@ function registerSessionCommands(pi: ExtensionAPI): void {
415
415
  });
416
416
  if (result.cancelled) ctx.ui.notify("Sub-agent session switch cancelled.", "warning");
417
417
  } catch (error) {
418
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
418
+ try {
419
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
420
+ } catch (notifyError) {
421
+ // If switchSession succeeded before a later callback threw, the old ctx is stale.
422
+ ignoreStaleExtensionContextError(notifyError);
423
+ }
419
424
  }
420
425
  },
421
426
  });
@@ -452,7 +457,12 @@ function registerSessionCommands(pi: ExtensionAPI): void {
452
457
  });
453
458
  if (result.cancelled) ctx.ui.notify("Return session switch cancelled.", "warning");
454
459
  } catch (error) {
455
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
460
+ try {
461
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
462
+ } catch (notifyError) {
463
+ // If switchSession succeeded before a later callback threw, the old ctx is stale.
464
+ ignoreStaleExtensionContextError(notifyError);
465
+ }
456
466
  }
457
467
  },
458
468
  });
@@ -29,8 +29,10 @@ export interface SubagentTypeConfig {
29
29
  export interface SubagentRoutingConfig {
30
30
  /** Ask a lightweight model to choose subagentType when a task omits it. */
31
31
  enabled?: boolean;
32
- /** Router model in provider/model form. Falls back to the current parent model if unavailable. */
32
+ /** Router model in provider/model form. Falls back to fallbackModels, then the current parent model, if unavailable. */
33
33
  model?: string;
34
+ /** Ordered router model fallbacks tried when the primary routing model is unavailable or fails. */
35
+ fallbackModels?: string[];
34
36
  /** Maximum task/scope characters sent to the router per task. */
35
37
  maxTaskChars?: number;
36
38
  /** Maximum router response tokens. */
@@ -145,10 +147,11 @@ export const DEFAULT_MAX_CONCURRENT = 5;
145
147
  export const DEFAULT_ROUTING_CONFIG: ResolvedSubagentRoutingConfig = {
146
148
  enabled: true,
147
149
  model: "zai/glm-4.5-air",
150
+ fallbackModels: ["openai-codex/gpt-5.3-codex-spark"],
148
151
  maxTaskChars: 1200,
149
152
  maxTokens: 512,
150
- maxRetries: 1,
151
- timeoutMs: 12_000,
153
+ maxRetries: 3,
154
+ timeoutMs: 10_000,
152
155
  debug: false,
153
156
  };
154
157
 
@@ -512,6 +515,8 @@ function normalizeRoutingConfig(value: Record<string, unknown>): SubagentRouting
512
515
  const routing: SubagentRoutingConfig = {};
513
516
  if (typeof value.enabled === "boolean") routing.enabled = value.enabled;
514
517
  if (typeof value.model === "string" && value.model.trim()) routing.model = value.model.trim();
518
+ const fallbackModels = modelList(value.fallbackModels, value.fallbackModel);
519
+ if (fallbackModels) routing.fallbackModels = fallbackModels;
515
520
  if (typeof value.debug === "boolean") routing.debug = value.debug;
516
521
  const maxTaskChars = finiteNumber(value.maxTaskChars);
517
522
  if (maxTaskChars !== undefined) routing.maxTaskChars = Math.max(100, Math.round(maxTaskChars));
@@ -54,35 +54,54 @@ export async function routeSubagentTasks(
54
54
  if (signal?.aborted) throw new Error("Aborted");
55
55
 
56
56
  try {
57
- const resolved = await resolveRoutingModel(ctx, routing);
58
- if (!resolved) {
57
+ const candidates = await resolveRoutingModels(ctx, routing);
58
+ if (candidates.length === 0) {
59
59
  const warning = `LLM sub-agent routing model unavailable (${routing.model}); used defaultType fallback.`;
60
60
  notifyRoutingWarning(ctx, routing, warning);
61
61
  return { tasks: fallbackTasks(), usedLlm: false, routes: {}, warnings: [warning] };
62
62
  }
63
63
 
64
- const response = await complete(
65
- resolved.model,
66
- {
67
- systemPrompt: ROUTER_SYSTEM_PROMPT,
68
- messages: [
64
+ const prompt = buildRoutingPrompt(autoTasks, config, routing);
65
+ const failures: string[] = [];
66
+ let response: RoutingResponse | undefined;
67
+ for (const candidate of candidates) {
68
+ if (signal?.aborted) throw new Error("Aborted");
69
+ try {
70
+ response = await complete(
71
+ candidate.model,
69
72
  {
70
- role: "user" as const,
71
- content: [{ type: "text" as const, text: buildRoutingPrompt(autoTasks, config, routing) }],
72
- timestamp: Date.now(),
73
+ systemPrompt: ROUTER_SYSTEM_PROMPT,
74
+ messages: [
75
+ {
76
+ role: "user" as const,
77
+ content: [{ type: "text" as const, text: prompt }],
78
+ timestamp: Date.now(),
79
+ },
80
+ ],
73
81
  },
74
- ],
75
- },
76
- {
77
- apiKey: resolved.apiKey,
78
- headers: resolved.headers,
79
- cacheRetention: "none",
80
- maxRetries: routing.maxRetries,
81
- maxTokens: routing.maxTokens,
82
- signal,
83
- timeoutMs: routing.timeoutMs,
84
- },
85
- );
82
+ {
83
+ apiKey: candidate.apiKey,
84
+ headers: candidate.headers,
85
+ cacheRetention: "none",
86
+ maxRetries: routing.maxRetries,
87
+ maxTokens: routing.maxTokens,
88
+ signal,
89
+ timeoutMs: routing.timeoutMs,
90
+ },
91
+ );
92
+ break;
93
+ } catch (error) {
94
+ if (signal?.aborted || isAbortError(error)) throw error;
95
+ failures.push(`${currentModelRef(candidate.model) ?? "(unknown)"}: ${errorMessage(error)}`);
96
+ }
97
+ }
98
+
99
+ if (!response) {
100
+ const warning = `LLM sub-agent routing failed (${failures.join("; ")}); used defaultType fallback.`;
101
+ notifyRoutingWarning(ctx, routing, warning);
102
+ return { tasks: fallbackTasks(), usedLlm: false, routes: {}, warnings: [warning] };
103
+ }
104
+
86
105
  const routes = parseRoutingResponse(responseText(response), config, autoTasks);
87
106
  const warnings = Object.keys(routes).length === autoTasks.length
88
107
  ? []
@@ -137,17 +156,33 @@ function buildRoutingPrompt(tasks: AgentTask[], config: SubagentConfig, routing:
137
156
  ].join("\n");
138
157
  }
139
158
 
140
- async function resolveRoutingModel(ctx: SubagentRoutingContext, routing: ResolvedSubagentRoutingConfig): Promise<{
159
+ async function resolveRoutingModels(
160
+ ctx: SubagentRoutingContext,
161
+ routing: ResolvedSubagentRoutingConfig,
162
+ ): Promise<RoutingCandidate[]> {
163
+ const candidates: RoutingCandidate[] = [];
164
+ const seen = new Set<string>();
165
+ const refs = [routing.model, ...routing.fallbackModels];
166
+ const parentModel = currentModelRef(ctx.model);
167
+ if (parentModel) refs.push(parentModel);
168
+ for (const ref of refs) {
169
+ const trimmed = ref?.trim();
170
+ if (!trimmed || seen.has(trimmed)) continue;
171
+ seen.add(trimmed);
172
+ const resolved = await resolveModelRef(ctx, trimmed);
173
+ if (resolved) candidates.push(resolved);
174
+ }
175
+ return candidates;
176
+ }
177
+
178
+ interface RoutingCandidate {
141
179
  model: Model<Api>;
142
180
  apiKey?: string;
143
181
  headers?: Record<string, string>;
144
- } | undefined> {
145
- const configured = await resolveModelRef(ctx, routing.model);
146
- if (configured) return configured;
147
- const parentModel = currentModelRef(ctx.model);
148
- return parentModel && parentModel !== routing.model ? resolveModelRef(ctx, parentModel) : undefined;
149
182
  }
150
183
 
184
+ type RoutingResponse = Awaited<ReturnType<typeof complete>>;
185
+
151
186
  async function resolveModelRef(ctx: SubagentRoutingContext, modelRef: string): Promise<{
152
187
  model: Model<Api>;
153
188
  apiKey?: string;
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { ignoreStaleExtensionContextError } from "../../context-usage";
2
3
 
3
4
  export const SUBAGENT_DENIED_TOOLS = new Set([
4
5
  "question",
@@ -23,10 +24,14 @@ export default function subagentToolGuard(pi: ExtensionAPI): void {
23
24
  };
24
25
 
25
26
  const applyGuard = () => {
26
- const activeTools = toolApi.getActiveTools?.() ?? [];
27
- const filtered = filterSubagentTools(activeTools) ?? [];
28
- if (filtered.length === activeTools.length) return;
29
- toolApi.setActiveTools?.(filtered);
27
+ try {
28
+ const activeTools = toolApi.getActiveTools?.() ?? [];
29
+ const filtered = filterSubagentTools(activeTools) ?? [];
30
+ if (filtered.length === activeTools.length) return;
31
+ toolApi.setActiveTools?.(filtered);
32
+ } catch (error) {
33
+ ignoreStaleExtensionContextError(error);
34
+ }
30
35
  };
31
36
 
32
37
  pi.on("session_start", applyGuard);
@@ -0,0 +1,98 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join, parse, resolve } from "node:path";
3
+ import { parse as parseJsonc } from "jsonc-parser";
4
+ import { getPiToolsSuiteUserConfigPath } from "../config.js";
5
+ import type { Strictness } from "./detect.js";
6
+
7
+ export interface CommentCheckerConfig {
8
+ enabled: boolean;
9
+ strictness: Strictness;
10
+ }
11
+
12
+ const DEFAULT_STRICTNESS: Strictness = "balanced";
13
+ const TRUE_VALUES = new Set(["1", "true", "on", "yes"]);
14
+ const FALSE_VALUES = new Set(["0", "false", "off", "no"]);
15
+
16
+ function isRecord(value: unknown): value is Record<string, unknown> {
17
+ return value !== null && typeof value === "object" && !Array.isArray(value);
18
+ }
19
+
20
+ function readJsonc(filePath: string): Record<string, unknown> {
21
+ if (!existsSync(filePath)) return {};
22
+ try {
23
+ const parsed = parseJsonc(readFileSync(filePath, "utf8"));
24
+ return isRecord(parsed) ? parsed : {};
25
+ } catch {
26
+ return {};
27
+ }
28
+ }
29
+
30
+ function findProjectConfig(startDir: string): string | undefined {
31
+ let dir = resolve(startDir);
32
+ const root = parse(dir).root;
33
+ while (true) {
34
+ const candidate = join(dir, ".pi", "pi-tools-suite.jsonc");
35
+ if (existsSync(candidate)) return candidate;
36
+ if (dir === root) return undefined;
37
+ const parent = dirname(dir);
38
+ if (parent === dir) return undefined;
39
+ dir = parent;
40
+ }
41
+ }
42
+
43
+ function normalizeStrictness(value: unknown, fallback: Strictness): Strictness {
44
+ if (value === "conservative" || value === "balanced" || value === "aggressive") return value;
45
+ return fallback;
46
+ }
47
+
48
+ function boolFromEnv(value: string | undefined): boolean | undefined {
49
+ if (value === undefined) return undefined;
50
+ const normalized = value.trim().toLowerCase();
51
+ if (TRUE_VALUES.has(normalized)) return true;
52
+ if (FALSE_VALUES.has(normalized)) return false;
53
+ return undefined;
54
+ }
55
+
56
+ let cachedConfig: CommentCheckerConfig | undefined;
57
+
58
+ /**
59
+ * Load the `commentChecker` section from the same layered config files as the
60
+ * rest of the suite (user shared config, $PI_CONFIG_DIR, nearest project
61
+ * `.pi/pi-tools-suite.jsonc`), then apply env overrides.
62
+ *
63
+ * Module disabling is handled by the top-level `disabledModules` loader in
64
+ * `config.ts`; this loader only reads the per-module `commentChecker` section
65
+ * (enabled toggle + strictness).
66
+ *
67
+ * Cached after the first read for the lifetime of the process.
68
+ */
69
+ export function loadCommentCheckerConfig(cwd: string = process.cwd(), env: NodeJS.ProcessEnv = process.env, homeDir: string = env.HOME ?? process.env.HOME ?? ""): CommentCheckerConfig {
70
+ if (cachedConfig) return cachedConfig;
71
+
72
+ let enabled = true;
73
+ let strictness: Strictness = DEFAULT_STRICTNESS;
74
+
75
+ const layers: string[] = [getPiToolsSuiteUserConfigPath(homeDir)];
76
+ if (env.PI_CONFIG_DIR) layers.push(join(env.PI_CONFIG_DIR, "pi-tools-suite.jsonc"));
77
+ const projectConfig = findProjectConfig(cwd);
78
+ if (projectConfig) layers.push(projectConfig);
79
+
80
+ for (const filePath of layers) {
81
+ const root = readJsonc(filePath);
82
+ const section = root.commentChecker;
83
+ if (!isRecord(section)) continue;
84
+ if (typeof section.enabled === "boolean") enabled = section.enabled;
85
+ if (section.strictness !== undefined) strictness = normalizeStrictness(section.strictness, strictness);
86
+ }
87
+
88
+ const envEnabled = boolFromEnv(env.PI_COMMENT_CHECKER_ENABLED);
89
+ if (envEnabled !== undefined) enabled = envEnabled;
90
+ if (env.PI_COMMENT_CHECKER_STRICTNESS) strictness = normalizeStrictness(env.PI_COMMENT_CHECKER_STRICTNESS, strictness);
91
+
92
+ cachedConfig = { enabled, strictness };
93
+ return cachedConfig;
94
+ }
95
+
96
+ export function __resetCommentCheckerConfigCache(): void {
97
+ cachedConfig = undefined;
98
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Pure-TypeScript comment-slop detector.
3
+ *
4
+ * No external binary, no network, no fs. Given the added/removed lines of a
5
+ * file edit, it finds net-new code comments (present in the added lines but
6
+ * not in the removed lines) that look unnecessary, while leaving genuinely
7
+ * valuable comments (TODO/FIXME, license headers, docstrings, pragmas, linter
8
+ * directives, shebangs, decorators) untouched.
9
+ *
10
+ * The mechanism mirrors oh-my-opencode comment-checker hook (the
11
+ * hasNewCommentsOnly heuristic) but replaces its external binary with an
12
+ * in-process classifier so the suite stays headless and pure-TS.
13
+ *
14
+ * Language-agnostic: recognizes comment markers from many languages:
15
+ * // /* * (C/C++/Java/C#/JS/TS/Rust/Go/Swift/Scala/Kotlin, JSDoc continuation)
16
+ * # (Python, Ruby, Shell, Perl, YAML, TOML, Makefile, PowerShell, R)
17
+ * -- (SQL, Lua, Haskell, Ada, Elm)
18
+ * <!-- --> (HTML, XML, SVG, Markdown)
19
+ * triple quotes (Python docstrings)
20
+ * : (some config/scripting dialects)
21
+ */
22
+
23
+ export type Strictness = "conservative" | "balanced" | "aggressive";
24
+
25
+ export interface CommentFinding {
26
+ filePath: string;
27
+ /** Absolute 1-based line number when resolvable from the written file. */
28
+ line?: number;
29
+ /** Full original comment line. */
30
+ text: string;
31
+ /** Classifier reason: restate-code | filler | decorative | generic-explanation | non-essential-comment. */
32
+ reason: string;
33
+ }
34
+
35
+ /**
36
+ * An extracted edit. removedLines are the lines being replaced (old text),
37
+ * addedLines are the new lines. For full-file writes, removedLines is empty
38
+ * because every comment in the new content is by definition newly added.
39
+ */
40
+ export interface Edit {
41
+ filePath: string;
42
+ removedLines: readonly string[];
43
+ addedLines: readonly string[];
44
+ /**
45
+ * Absolute 1-based line number of the FIRST line in `addedLines` within the
46
+ * target file, when known. Used to report accurate finding line numbers.
47
+ * For full-file writes this is 1; for edits/apply_patch it is resolved by
48
+ * locating the block in the already-written file; when unknown, undefined.
49
+ */
50
+ baseLineNumber?: number;
51
+ }
52
+
53
+ const MARKER_RE = /^\s*(\/\/+|\/\*+|\*+|#+|--+|<!--|:\s*)/;
54
+ const PY_STRING_OPEN_RE = /^\s*("""|''')/;
55
+
56
+ /**
57
+ * Strip the comment marker and surrounding whitespace from a line.
58
+ * Returns the comment body, or null when the line is not a comment.
59
+ */
60
+ export function commentBody(line: string): string | null {
61
+ const pyString = line.match(PY_STRING_OPEN_RE);
62
+ if (pyString) {
63
+ const body = line.slice(pyString[0].length).replace(/("""|''')\s*$/, "").trim();
64
+ return body.length > 0 ? body : null;
65
+ }
66
+
67
+ const match = line.match(MARKER_RE);
68
+ if (!match) return null;
69
+
70
+ const body = line.slice(match[0].length).replace(/\*\/\s*$/, "").trim();
71
+ return body.length > 0 ? body : null;
72
+ }
73
+
74
+ const VALUABLE_RE: readonly RegExp[] = [
75
+ // Task / review markers with optional context.
76
+ /\b(TODO|FIXME|XXX|HACK|NOTE|WARNING|WARN|BUG|OPTIMIZE|OPTIMISE|REFACTOR|DEPRECATED|SAFETY|SECURITY|PERF|PERFORMANCE|CHANGED|REVIEW|QUESTION|IDEA)\b[:\s()]/i,
77
+ // License / copyright headers.
78
+ /\bspdx-license-identifier\b/i,
79
+ /\b(copyright|\(c\))\b/i,
80
+ /\blicensed under\b/i,
81
+ /\blicense(d)?\b.*\b(MIT|Apache|GPL|LGPL|AGPL|BSD|MPL|ISC|Unlicense)\b/i,
82
+ // Linter / formatter directives.
83
+ /\b(eslint|tslint|biome|prettier|stylelint|jshint|jscs|cspell|spellcheck)\b[-:]/i,
84
+ /@typescript-eslint\//i,
85
+ // Hash-prefixed directives: C/C++ preprocessor, Python/SQL pragmas, tool ignores.
86
+ /^\s*#\s*(include|define|undef|ifdef|ifndef|if|elif|else|endif|error|warning|pragma|import|line|region|endregion|type:\s*ignore|noqa|nosec|pyright|mypy|isort|ruff|flake8|bandit|safety)\b/i,
87
+ // Shebang.
88
+ /^\s*#!/,
89
+ // Decorators and JSDoc/annotation tags.
90
+ /^\s*@/,
91
+ /\b@(param|returns?|throws?|see|example|deprecated|internal|public|private|protected|readonly|override|ts-ignore|ts-expect-error|ts-check|ts-nocheck|abstract|generic|type|template|hidden|alpha|beta|experimental|since|version|author|license|category|remarks)\b/i,
92
+ // File-disable / allow bypass markers.
93
+ /comment-checker-disable-file/i,
94
+ /^\s*@allow\b/i,
95
+ // JSDoc / block docstring openers and rust doc.
96
+ /^\s*(\/\*\*|\/\/!|\/\/\/)/,
97
+ // Editor modelines and schema hints.
98
+ /\bvim:|\bex: set|\bmodeline\b|\bsts=|\bts=\d/i,
99
+ /\$schema\b/i,
100
+ /@type\b/i,
101
+ // Region / folding markers.
102
+ /{#[^}]*#}|^\s*#?(end )?region\b/i,
103
+ ];
104
+
105
+ function isValuable(body: string, rawLine: string): boolean {
106
+ if (body.length === 0) return true;
107
+ for (const re of VALUABLE_RE) {
108
+ if (re.test(rawLine) || re.test(body)) return true;
109
+ }
110
+ return false;
111
+ }
112
+
113
+ const SYMBOL_RUN_RE = /[-=*_~#]{3,}/;
114
+ const SYMBOLS_ONLY_RE = /^[=\-*_~#|<>.,:;\s]+$/;
115
+
116
+ function decorativeReason(body: string): "decorative" | null {
117
+ if (body.length < 3) return null;
118
+ if (SYMBOLS_ONLY_RE.test(body) && SYMBOL_RUN_RE.test(body)) return "decorative";
119
+ return null;
120
+ }
121
+
122
+ const FILLER_RE =
123
+ /^(simply|obviously|clearly|just|basically|note that|please note|as you can see|as mentioned|as discussed|as shown|needless to say|of course|it goes without saying|important to note|worth noting|keep in mind|bear in mind|this (is|was|will|should|does|has|had))\b/i;
124
+
125
+ const RESTATE_RE =
126
+ /^(returns?|creates?|sets?|gets?|fetches?|checks? if|checks? whether|loops? (over|through|around)|iterates? (over|through)|increments?|decrements?|initializes?|initialises?|defines?|declares?|calls?|invokes?|imports?|exports?|logs?|prints?|assigns?|updates?|removes?|adds?|deletes?|handles?|processes?|computes?|calculates?|reads?|writes?|opens?|closes?|starts?|stops?|begins?|ends?|stores?|saves?|loads?|parses?|validates?|verifies?)\b/i;
127
+
128
+ const GENERIC_RE =
129
+ /^(this (function|method|code|line|block|class|module|file|variable|constant|loop|statement|section|part|snippet|component|hook|handler|guard|helper|util|utility|wrapper|factory)|here we|here i|now we|now let'?s|let'?s|in this|the (above|following|next|previous|code|function|method|loop|block|statement)|with this|using this|first|then|finally|next|after that|step \d|a (helper|utility|wrapper|factory) (that|to|for)|workaround|hack:|note:|caveat:|disclaimer:)/i;
130
+
131
+ function classifySlop(body: string, strictness: Strictness): string | null {
132
+ const decorative = decorativeReason(body);
133
+ if (decorative) return decorative;
134
+
135
+ if (FILLER_RE.test(body)) return "filler";
136
+
137
+ if (strictness === "conservative") return null;
138
+
139
+ if (RESTATE_RE.test(body)) return "restate-code";
140
+
141
+ if (strictness === "balanced") {
142
+ if (GENERIC_RE.test(body)) return "generic-explanation";
143
+ return null;
144
+ }
145
+
146
+ // aggressive: any remaining non-valuable comment is flagged.
147
+ return "non-essential-comment";
148
+ }
149
+
150
+ function removedCommentSignatures(removedLines: readonly string[]): Set<string> {
151
+ const set = new Set<string>();
152
+ for (const line of removedLines) {
153
+ const body = commentBody(line);
154
+ if (body !== null) set.add(line.trim());
155
+ }
156
+ return set;
157
+ }
158
+
159
+ /**
160
+ * Detect net-new unnecessary comments across a set of edits.
161
+ * Returns at most maxFindings findings (per file, then overall) to keep the
162
+ * tool-result nudge concise.
163
+ */
164
+ export function detectSlopComments(edits: readonly Edit[], strictness: Strictness, maxFindings = 8): CommentFinding[] {
165
+ const findings: CommentFinding[] = [];
166
+
167
+ for (const edit of edits) {
168
+ if (!edit.filePath) continue;
169
+ const removed = removedCommentSignatures(edit.removedLines);
170
+
171
+ // Track /* ... */ block comments (including /** JSDoc/docstrings) so that
172
+ // interior continuation lines (e.g. ` * Adds two numbers.`) are not
173
+ // individually flagged. Block-comment content is evaluated as a whole;
174
+ // only single-line `/* foo */` inline comments fall through to classification.
175
+ let inBlockComment = false;
176
+
177
+ let indexInBlock = 0;
178
+ for (const line of edit.addedLines) {
179
+ if (findings.length >= maxFindings) break;
180
+
181
+ const hasOpen = line.includes("/*");
182
+ const hasClose = line.includes("*/");
183
+
184
+ if (inBlockComment) {
185
+ if (hasClose) inBlockComment = false;
186
+ indexInBlock++;
187
+ continue;
188
+ }
189
+
190
+ if (hasOpen && !hasClose) {
191
+ // Opener of a multi-line block comment / docstring. Skip it and the
192
+ // following continuation lines until the matching `*/`.
193
+ inBlockComment = true;
194
+ indexInBlock++;
195
+ continue;
196
+ }
197
+
198
+ const body = commentBody(line);
199
+ const lineOffset = indexInBlock;
200
+ indexInBlock++;
201
+ if (body === null) continue;
202
+ if (removed.has(line.trim())) continue;
203
+ if (isValuable(body, line)) continue;
204
+ const reason = classifySlop(body, strictness);
205
+ if (!reason) continue;
206
+
207
+ const absoluteLine = edit.baseLineNumber !== undefined ? edit.baseLineNumber + lineOffset : undefined;
208
+ findings.push({ filePath: edit.filePath, line: absoluteLine, text: line.trim(), reason });
209
+ }
210
+
211
+ if (findings.length >= maxFindings) break;
212
+ }
213
+
214
+ return findings;
215
+ }