decorated-pi 0.2.2 → 0.3.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.
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import * as fs from "node:fs";
11
- import { resolve } from "node:path";
11
+ import { basename, extname, resolve } from "node:path";
12
12
 
13
13
  const DANGEROUS_COMMANDS: [string, string[]][] = [
14
14
  ["rm", []],
@@ -332,7 +332,7 @@ export function shannonEntropy(data: string): number {
332
332
  * Trigram (3-character sliding window) scoring.
333
333
  * Rules (user-specified):
334
334
  * - Pure digits → 0
335
- * - Letter↔Digit switch (digit in first 2 positions, e.g. 4Vi, K9m, a9t) → 1.0
335
+ * - Letter↔Digit switch (digit in first position, e.g. 4Vi) → 1.0
336
336
  * - Contains '-' with ≥3 distinct classes → 1.0
337
337
  * - Case switch AbA pattern (≥2 uppercase + ≥1 lowercase) → 0.8
338
338
  * - Otherwise → 0
@@ -351,10 +351,10 @@ export function trigramScore(c1: string, c2: string, c3: string): number {
351
351
  // Contains '-' (S-class) with ≥3 distinct classes → 1.0
352
352
  if (cls.includes("S") && unique.size >= 3) return 1.0;
353
353
 
354
- // Letter↔Digit: digit must be in first 2 positions
354
+ // Letter↔Digit: digit must be in first position
355
355
  const hasDigit = cls.includes("D");
356
356
  const hasLetter = cls.includes("L") || cls.includes("U");
357
- if (hasDigit && hasLetter && (cls[0] === "D" || cls[1] === "D")) return 1.0;
357
+ if (hasDigit && hasLetter && cls[0] === "D") return 1.0;
358
358
 
359
359
  // AbA pattern: ≥2 uppercase + ≥1 lowercase (e.g. KeA, but not API)
360
360
  const uCount = cls.filter(c => c === "U").length;
@@ -412,35 +412,26 @@ export function maxSegmentDensity(token: string): number {
412
412
 
413
413
  /**
414
414
  * Word ratio: fraction of token that consists of vowel-containing
415
- * lowercase fragments ≥3 characters. Natural language words reduce
416
- * the likelihood of being a secret.
415
+ * alphabetic fragments ≥3 characters, case-insensitive. Natural language
416
+ * words reduce the likelihood of being a secret.
417
417
  */
418
418
  export function computeWordRatio(token: string): number {
419
- // Split by class boundaries
420
- const segments: string[] = [];
419
+ const letterSeqs: string[] = [];
421
420
  let current = "";
422
- let prevClass = "";
423
421
  for (const c of token) {
424
422
  const cls = charClass(c);
425
- if (cls === "X") {
426
- if (current.length > 0) { segments.push(current); current = ""; }
427
- prevClass = "";
428
- continue;
429
- }
430
- if (cls !== prevClass && current.length > 0) {
431
- segments.push(current);
423
+ if (cls === "L" || cls === "U") {
424
+ current += c.toLowerCase();
425
+ } else {
426
+ if (current.length >= 3) letterSeqs.push(current);
432
427
  current = "";
433
428
  }
434
- current += c;
435
- prevClass = cls;
436
429
  }
437
- if (current.length > 0) segments.push(current);
430
+ if (current.length >= 3) letterSeqs.push(current);
438
431
 
439
432
  let wordLen = 0;
440
- for (const seg of segments) {
441
- if (seg.length >= 3 && /^[a-z]+$/.test(seg)) {
442
- if (/[aeiou]/.test(seg)) wordLen += seg.length;
443
- }
433
+ for (const seq of letterSeqs) {
434
+ if (/[aeiou]/.test(seq)) wordLen += seq.length;
444
435
  }
445
436
  return token.length > 0 ? wordLen / token.length : 0;
446
437
  }
@@ -474,20 +465,22 @@ const DICT_WORDS: ReadonlySet<string> = new Set(
474
465
  /**
475
466
  * Dictionary word ratio: fraction of token characters covered by dictionary words.
476
467
  *
477
- * Extracts lowercase letter sequences from the token, then greedily matches
478
- * the longest dictionary word at each position. Returns matched character
468
+ * Extracts alphabetic sequences from the token (case-insensitive), then greedily
469
+ * matches the longest dictionary word at each position. Returns matched character
479
470
  * count / token length.
480
471
  *
481
472
  * "devstral-small-2" → finds "dev", "str", "small" → covers 11/16 chars
482
- * "aB3xK9mPqR7wN" no words found dictRatio = 0
473
+ * "NET_CHANNEL_INFO_REPORT_V20" finds "net", "channel", "info", "report"
474
+ * "aB3xK9mPqR7wN" → no words found → dictRatio = 0
483
475
  */
484
476
  export function computeDictRatio(token: string): number {
485
- // Extract lowercase letter sequences (>= 3 chars)
477
+ // Extract alphabetic sequences (>= 3 chars), case-insensitive
486
478
  const lowerSeqs: string[] = [];
487
479
  let current = "";
488
480
  for (const c of token) {
489
- if (/[a-z]/.test(c)) {
490
- current += c;
481
+ const cls = charClass(c);
482
+ if (cls === "L" || cls === "U") {
483
+ current += c.toLowerCase();
491
484
  } else {
492
485
  if (current.length >= 3) lowerSeqs.push(current);
493
486
  current = "";
@@ -524,7 +517,7 @@ export function computeDictRatio(token: string): number {
524
517
  // ── Entropy Constants ────────────────────────────────────────────────────────
525
518
 
526
519
  export const ENTROPY_THRESHOLD = 5.5;
527
- export const MIN_ENTROPY_TOKEN_LENGTH = 16;
520
+ export const MIN_ENTROPY_TOKEN_LENGTH = 32;
528
521
  export const W1_DENSITY = 3.0;
529
522
  export const W2_WORD = 3.0;
530
523
  export const W3_DICT = 4.0;
@@ -657,19 +650,118 @@ export function isSafeContent(content: string): boolean {
657
650
 
658
651
  // ── Detector ─────────────────────────────────────────────────────────────────
659
652
 
653
+ export type SecretMatchSource = "pattern" | "regex" | "entropy";
654
+
660
655
  export interface SecretMatch {
661
656
  name: string;
662
657
  start: number;
663
658
  end: number;
664
659
  original: string;
660
+ source: SecretMatchSource;
661
+ }
662
+
663
+ export interface DetectSecretsOptions {
664
+ filePath?: string;
665
+ }
666
+
667
+ interface ConfigStringEntry {
668
+ key: string;
669
+ normalizedKey: string;
670
+ value: string;
671
+ start: number;
672
+ end: number;
665
673
  }
666
674
 
667
675
  const MIN_SCAN_LENGTH = 10;
676
+ const CONFIG_VALUE_MIN_LENGTH = 32;
677
+ const CONFIG_FILE_EXTENSIONS = new Set([
678
+ ".json", ".jsonc", ".env", ".toml", ".yaml", ".yml",
679
+ ".ini", ".cfg", ".conf", ".properties",
680
+ ]);
681
+ const CONFIG_BASENAME_REGEX = /^\.env(?:\..+)?$/i;
682
+ const SENSITIVE_CONFIG_KEY_REGEX = /(?:^|_)(?:apikey|api_(?:key|secret|token)|access_(?:key|token)|refresh_token|client_secret|secret(?:_key)?|private_key|bearer_token|auth(?:orization|_token)?|pass(?:word|wd)?|pwd|token|webhook_secret)(?:_|$)/i;
683
+ const PLACEHOLDER_VALUE_REGEX = /^(?:\$\{[^}]+\}|\{\{[^}]+\}\}|<[^>]+>|xxx+|placeholder|example|sample|demo|test|changeme|your[_-]?(?:api[_-]?)?key(?:[_-]?here)?)$/i;
684
+ const CONFIG_STRING_PATTERNS: RegExp[] = [
685
+ /(?<key>"[^"\r\n]+"|'[^'\r\n]+'|[A-Za-z0-9_.-]+)\s*[:=]\s*"(?<value>(?:\\.|[^"\\])*)"/g,
686
+ /(?<key>"[^"\r\n]+"|'[^'\r\n]+'|[A-Za-z0-9_.-]+)\s*[:=]\s*'(?<value>(?:\\.|[^'\\])*)'/g,
687
+ /(?<key>[A-Za-z0-9_.-]+)\s*=\s*(?<value>[^\r\n#;]+)/g,
688
+ ];
689
+
690
+ function normalizeConfigKey(key: string): string {
691
+ return key
692
+ .trim()
693
+ .replace(/^['"]|['"]$/g, "")
694
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
695
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
696
+ .toLowerCase()
697
+ .replace(/[.\-\s]+/g, "_")
698
+ .replace(/_+/g, "_")
699
+ .replace(/^_+|_+$/g, "");
700
+ }
701
+
702
+ function isConfigLikeFile(filePath?: string): boolean {
703
+ if (!filePath) return false;
704
+ const name = basename(filePath);
705
+ if (CONFIG_BASENAME_REGEX.test(name)) return true;
706
+ return CONFIG_FILE_EXTENSIONS.has(extname(name).toLowerCase());
707
+ }
708
+
709
+ function looksLikeSensitiveConfigValue(value: string): boolean {
710
+ const trimmed = value.trim();
711
+ if (!trimmed) return false;
712
+ if (PLACEHOLDER_VALUE_REGEX.test(trimmed)) return false;
713
+ if (isSafeContent(trimmed)) return false;
714
+ if (/^(?:true|false|null)$/i.test(trimmed)) return false;
715
+ if (/^[+-]?\d+(?:\.\d+)?$/.test(trimmed)) return false;
716
+ return trimmed.length >= CONFIG_VALUE_MIN_LENGTH;
717
+ }
718
+
719
+ function extractConfigStringEntries(content: string): ConfigStringEntry[] {
720
+ const entries: ConfigStringEntry[] = [];
721
+ const seen = new Set<string>();
722
+
723
+ for (const pattern of CONFIG_STRING_PATTERNS) {
724
+ for (const match of content.matchAll(pattern)) {
725
+ const key = match.groups?.key;
726
+ const value = match.groups?.value;
727
+ if (!key || value === undefined || match.index === undefined) continue;
728
+ const full = match[0] ?? "";
729
+ const rel = full.indexOf(value);
730
+ if (rel < 0) continue;
731
+ const start = match.index + rel;
732
+ const end = start + value.length;
733
+ const dedupeKey = `${start}-${end}`;
734
+ if (seen.has(dedupeKey)) continue;
735
+ seen.add(dedupeKey);
736
+ entries.push({
737
+ key,
738
+ normalizedKey: normalizeConfigKey(key),
739
+ value,
740
+ start,
741
+ end,
742
+ });
743
+ }
744
+ }
745
+
746
+ return entries;
747
+ }
748
+
749
+ function addMatch(matches: SecretMatch[], seen: Set<string>, match: SecretMatch): void {
750
+ const key = `${match.start}-${match.end}`;
751
+ if (seen.has(key)) return;
752
+ seen.add(key);
753
+ matches.push(match);
754
+ }
755
+
756
+ function isCoveredByExistingMatch(matches: SecretMatch[], start: number, end: number): boolean {
757
+ return matches.some((existing) => !(end <= existing.start || start >= existing.end));
758
+ }
668
759
 
669
- export function detectSecrets(content: string): SecretMatch[] {
760
+ export function detectSecrets(content: string, options: DetectSecretsOptions = {}): SecretMatch[] {
670
761
  if (content.length < MIN_SCAN_LENGTH) return [];
671
762
  const matches: SecretMatch[] = [];
672
- const seen = new Set<string>(); // deduplicate by position
763
+ const seen = new Set<string>();
764
+ const configLike = isConfigLikeFile(options.filePath);
673
765
 
674
766
  // Pass 1: High-confidence pattern matching (specific prefixes like ghp_, AKIA)
675
767
  for (const sp of SECRET_PATTERNS) {
@@ -677,60 +769,63 @@ export function detectSecrets(content: string): SecretMatch[] {
677
769
  if (content.length < sp.minLength) continue;
678
770
  for (const m of content.matchAll(new RegExp(sp.pattern.source, sp.pattern.flags + "g"))) {
679
771
  const text = m[0];
680
- if (!text) continue;
772
+ if (!text || m.index === undefined) continue;
681
773
  if (!sp.allowsSpaces && text.includes(" ")) continue;
682
- const key = `${m.index}-${m.index + text.length}`;
683
- if (seen.has(key)) continue;
684
- seen.add(key);
685
- matches.push({ name: sp.name, start: m.index!, end: m.index! + text.length, original: text });
774
+ addMatch(matches, seen, {
775
+ name: sp.name,
776
+ start: m.index,
777
+ end: m.index + text.length,
778
+ original: text,
779
+ source: "pattern",
780
+ });
686
781
  }
687
782
  }
688
783
 
689
- // Pass 2: Low-confidence pattern matching (generic assignments like secret=xxx)
690
- // Skip ranges already covered by high-confidence matches
691
- for (const sp of SECRET_PATTERNS) {
692
- if (sp.highConfidence) continue;
693
- if (content.length < sp.minLength) continue;
694
- for (const m of content.matchAll(new RegExp(sp.pattern.source, sp.pattern.flags + "g"))) {
695
- const text = m[0];
696
- if (!text) continue;
697
- if (!sp.allowsSpaces && text.includes(" ")) continue;
698
- // Check against safe patterns to reduce false positives
699
- if (isSafeContent(text)) continue;
700
- // Also check surrounding context (e.g. "your_api_key=xxx" is a placeholder)
701
- const contextStart = Math.max(0, m.index! - 10);
702
- const context = content.slice(contextStart, m.index! + text.length);
703
- if (isSafeContent(context)) continue;
704
- // Skip if range already covered by a high-confidence match
705
- const start = m.index!, end = m.index! + text.length;
706
- if (matches.some(hc => hc.start <= start && hc.end >= end)) continue;
707
- const key = `${start}-${end}`;
708
- if (seen.has(key)) continue;
709
- seen.add(key);
710
- matches.push({ name: sp.name, start, end, original: text });
784
+ if (configLike) {
785
+ const entries = extractConfigStringEntries(content);
786
+
787
+ // Pass 2: Regex key-name matching for config-like files only
788
+ for (const entry of entries) {
789
+ if (!SENSITIVE_CONFIG_KEY_REGEX.test(entry.normalizedKey)) continue;
790
+ if (!looksLikeSensitiveConfigValue(entry.value)) continue;
791
+ if (isCoveredByExistingMatch(matches, entry.start, entry.end)) continue;
792
+ addMatch(matches, seen, {
793
+ name: `Sensitive config key: ${entry.normalizedKey}`,
794
+ start: entry.start,
795
+ end: entry.end,
796
+ original: entry.value,
797
+ source: "regex",
798
+ });
711
799
  }
712
- }
713
800
 
714
- // Pass 3: Entropy analysis (catches unknown formats like third-party sk- keys)
715
- const highEntropyTokens = findHighEntropyTokens(content);
716
- for (const token of highEntropyTokens) {
717
- if (isSafeContent(token)) continue;
718
- const idx = content.indexOf(token);
719
- if (idx === -1) continue;
720
- // Skip if already covered by a pattern match
721
- if (matches.some(m => m.start <= idx && m.end >= idx + token.length)) continue;
722
- const key = `${idx}-${idx + token.length}`;
723
- if (seen.has(key)) continue;
724
- seen.add(key);
725
- matches.push({ name: "High Entropy String", start: idx, end: idx + token.length, original: token });
801
+ // Pass 3: Entropy analysis for config-like files only
802
+ for (const entry of entries) {
803
+ if (isCoveredByExistingMatch(matches, entry.start, entry.end)) continue;
804
+ if (!looksLikeSensitiveConfigValue(entry.value)) continue;
805
+ if (!isHighEntropy(entry.value)) continue;
806
+ addMatch(matches, seen, {
807
+ name: "High Entropy String",
808
+ start: entry.start,
809
+ end: entry.end,
810
+ original: entry.value,
811
+ source: "entropy",
812
+ });
813
+ }
726
814
  }
727
815
 
728
816
  // Sort by start position descending for safe right-to-left replacement
729
817
  return matches.sort((a, b) => b.start - a.start);
730
818
  }
731
819
 
732
- export function maskSecret(text: string): string {
733
- if (text.length <= 8) return "********";
734
- return text.slice(0, 4) + "********" + text.slice(-4);
820
+ function getMaskChar(source?: SecretMatchSource): string {
821
+ if (source === "regex") return "#";
822
+ if (source === "entropy") return "?";
823
+ return "*";
824
+ }
825
+
826
+ export function maskSecret(text: string, source?: SecretMatchSource): string {
827
+ const maskChar = getMaskChar(source);
828
+ if (text.length <= 6) return maskChar.repeat(text.length);
829
+ return text.slice(0, 3) + maskChar.repeat(text.length - 6) + text.slice(-3);
735
830
  }
736
831
 
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * - Command Guard: 拦截危险 bash 命令
5
5
  * - Redirect Guard: bash 覆盖写入提示确认
6
- * - Protected Paths: write/edit/read 保护路径提示确认
7
- * - Write Guard: 覆盖非空文件禁止 write
6
+ * - Protected Paths: write/edit/patch/read 保护路径提示确认
7
+ * - Write Guard: 覆盖非空文件禁止 write (提示使用 patch)
8
8
  * - Secret Redact: API Key / Token 自动掩码
9
9
  */
10
10
 
@@ -25,6 +25,26 @@ import {
25
25
 
26
26
  type ToolTextContent = Extract<NonNullable<ToolResultEvent["content"]>[number], { type: "text" }>;
27
27
 
28
+ function summarizeCommand(command: string, maxLength = 48): string {
29
+ const singleLine = command.replace(/\s+/g, " ").trim();
30
+ if (singleLine.length <= maxLength) return singleLine;
31
+ return `${singleLine.slice(0, maxLength - 1)}…`;
32
+ }
33
+
34
+ function formatRedactionContext(event: ToolResultEvent): string {
35
+ if (event.toolName === "read") {
36
+ const filePath = (event.input as any)?.path ?? (event.input as any)?.file ?? (event.input as any)?.file_path;
37
+ return filePath ? `read ${filePath}` : "read";
38
+ }
39
+ if (event.toolName === "bash") {
40
+ const command = (event.input as any)?.command;
41
+ return typeof command === "string" && command.trim().length > 0
42
+ ? `bash ${summarizeCommand(command)}`
43
+ : "bash";
44
+ }
45
+ return event.toolName;
46
+ }
47
+
28
48
  // ─── Setup ──────────────────────────────────────────────────────────────────
29
49
 
30
50
  export function setupSafety(pi: ExtensionAPI) {
@@ -53,10 +73,13 @@ export function setupSafety(pi: ExtensionAPI) {
53
73
  }
54
74
  }
55
75
 
56
- // Gate 2: write/edit 写入保护路径
57
- if (event.toolName === "write" || event.toolName === "edit") {
58
- const filePath = (event.input as any).path ?? (event.input as any).file ?? (event.input as any).file_path;
59
- if (filePath) {
76
+ // Gate 2: write/edit/patch 写入保护路径
77
+ if (event.toolName === "write" || event.toolName === "edit" || event.toolName === "patch") {
78
+ // For write/edit, path is a single field; for patch, check all patches[].path
79
+ const filePaths: string[] = event.toolName === "patch"
80
+ ? (event.input as any).patches?.filter((p: any) => p?.path).map((p: any) => p.path) ?? []
81
+ : [(event.input as any).path ?? (event.input as any).file ?? (event.input as any).file_path].filter(Boolean);
82
+ for (const filePath of filePaths) {
60
83
  const danger = checkProtectedPath(filePath);
61
84
  if (danger) {
62
85
  if (!ctx.hasUI) {
@@ -69,6 +92,7 @@ export function setupSafety(pi: ExtensionAPI) {
69
92
  if (!choice || choice === "Block") {
70
93
  return { block: true, reason: `🔒 ${danger}\nmay contain sensitive information` };
71
94
  }
95
+ break; // User approved — skip remaining paths
72
96
  }
73
97
  }
74
98
  }
@@ -80,7 +104,7 @@ export function setupSafety(pi: ExtensionAPI) {
80
104
  try {
81
105
  const abs = resolve(ctx.cwd, filePath);
82
106
  if (fs.existsSync(abs) && fs.readFileSync(abs, "utf8").length > 0) {
83
- return { block: true, reason: "Overwriting a non-empty file is dangerous, use the edit tool instead!" };
107
+ return { block: true, reason: "Overwriting a non-empty file is dangerous, use the patch tool instead!" };
84
108
  }
85
109
  } catch { /* file doesn't exist */ }
86
110
  }
@@ -115,9 +139,10 @@ export function setupSafety(pi: ExtensionAPI) {
115
139
  ): Promise<{ content?: NonNullable<ToolResultEvent["content"]> } | void> => {
116
140
  if (!event.content || !Array.isArray(event.content)) return;
117
141
 
118
- // Only scan read tool output other tools (bash, write, edit) are either
119
- // covered by path guards or produce git/diff noise that causes false positives.
120
- if (event.toolName !== "read") return;
142
+ // Scan read + bash tool output. Skip write/edit/patch because they mainly
143
+ // produce diffs or generated file bodies, which are handled elsewhere and are
144
+ // more prone to noisy false positives.
145
+ if (event.toolName !== "read" && event.toolName !== "bash") return;
121
146
 
122
147
  const textParts: Array<{ index: number; text: string; item: ToolTextContent }> = [];
123
148
  for (let i = 0; i < event.content.length; i++) {
@@ -129,17 +154,25 @@ export function setupSafety(pi: ExtensionAPI) {
129
154
  if (textParts.length === 0) return;
130
155
 
131
156
  let totalCount = 0;
157
+ const counts: Record<"pattern" | "regex" | "entropy", number> = {
158
+ pattern: 0,
159
+ regex: 0,
160
+ entropy: 0,
161
+ };
132
162
  const newContent = [...event.content];
133
163
 
164
+ const filePath = (event.input as any)?.path ?? (event.input as any)?.file ?? (event.input as any)?.file_path;
165
+
134
166
  for (const { index, text, item } of textParts) {
135
- const matches = detectSecrets(text);
167
+ const matches = detectSecrets(text, { filePath });
136
168
  if (matches.length === 0) continue;
137
169
 
138
170
  totalCount += matches.length;
139
171
  let redacted = text;
140
- for (const { start, end } of matches) {
172
+ for (const { start, end, source } of matches) {
173
+ counts[source] += 1;
141
174
  const original = redacted.slice(start, end);
142
- redacted = redacted.slice(0, start) + maskSecret(original) + redacted.slice(end);
175
+ redacted = redacted.slice(0, start) + maskSecret(original, source) + redacted.slice(end);
143
176
  }
144
177
  const updatedItem: ToolTextContent = { ...item, text: redacted };
145
178
  newContent[index] = updatedItem;
@@ -147,9 +180,15 @@ export function setupSafety(pi: ExtensionAPI) {
147
180
 
148
181
  if (totalCount === 0) return;
149
182
  const label = totalCount === 1 ? "1 secret" : `${totalCount} secrets`;
150
- ctx.ui.notify(`🔒 Redacted ${label} in ${event.toolName} output`, "warning");
183
+ const breakdown: string[] = [];
184
+ if (counts.pattern > 0) breakdown.push(`*:pattern=${counts.pattern}`);
185
+ if (counts.regex > 0) breakdown.push(`#:regex=${counts.regex}`);
186
+ if (counts.entropy > 0) breakdown.push(`?:entropy=${counts.entropy}`);
187
+ const suffix = breakdown.length > 0 ? ` · ${breakdown.join(" ")}` : "";
188
+ const contextLabel = formatRedactionContext(event);
189
+ ctx.ui.notify(`🔒 [${contextLabel}] Redacted ${label}${suffix}`, "warning");
151
190
  return { content: newContent };
152
191
  };
153
192
 
154
193
  pi.on("tool_result", handleToolResult);
155
- }
194
+ }
@@ -29,6 +29,7 @@ export interface ModuleSettings {
29
29
  safety?: boolean;
30
30
  lsp?: boolean;
31
31
  "smart-at"?: boolean;
32
+ patch?: boolean;
32
33
  }
33
34
 
34
35
  export interface DecoratedPiConfig {
@@ -111,6 +112,7 @@ const DEFAULT_MODULES: Required<ModuleSettings> = {
111
112
  safety: true,
112
113
  lsp: true,
113
114
  "smart-at": true,
115
+ patch: true,
114
116
  };
115
117
 
116
118
  export function isModuleEnabled(name: keyof ModuleSettings): boolean {
@@ -2,14 +2,14 @@
2
2
  * Slash — 所有扩展命令
3
3
  *
4
4
  * /dp-model → 模型选择器 (TAB 切换 Image/Compact)
5
- * /dp-settings → 模块开关 (safety / lsp / smart-at)
5
+ * /dp-settings → 模块开关 (patch / safety / lsp / smart-at)
6
6
  * /retry → 中断后继续
7
7
  */
8
8
 
9
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
- import { ModelPickerComponent } from "./extend-model.js";
9
+ import type { ExtensionAPI, ExtensionContext, Theme as PiTheme } from "@earendil-works/pi-coding-agent";
10
+ import { ModelPickerComponent } from "./model-integration.js";
11
11
  import { getAllModuleSettings, setModuleEnabled, type ModuleSettings } from "./settings.js";
12
- import { Container, SettingsList, type TUI, type Theme as PiTheme, type SettingsListTheme, type Component } from "@earendil-works/pi-tui";
12
+ import { Container, SettingsList, type TUI, type SettingsListTheme, type Component } from "@earendil-works/pi-tui";
13
13
 
14
14
  // ─── Border component (matches native DynamicBorder) ────────────────────────
15
15
 
@@ -60,12 +60,14 @@ function setupDpModelCommand(pi: ExtensionAPI) {
60
60
  // ─── /dp-settings ──────────────────────────────────────────────────────────
61
61
 
62
62
  const MODULE_LABELS: Record<keyof ModuleSettings, string> = {
63
+ patch: "Patch Tool",
63
64
  safety: "Safety Layer",
64
65
  lsp: "LSP Tools",
65
66
  "smart-at": "Smart @ Search",
66
67
  };
67
68
 
68
69
  const MODULE_DESCS: Record<keyof ModuleSettings, string> = {
70
+ patch: "Replace edit/write with patch tool (old_str/new_str replacement + overwrite)",
69
71
  safety: "Command guard, protected paths, read guard, secret redaction",
70
72
  lsp: "Language server diagnostics, hover, definition, references, symbols, rename",
71
73
  "smart-at": "Project-aware file search replacing default autocomplete",