omp-plugin-duplicate-detector 0.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.
package/src/index.ts ADDED
@@ -0,0 +1,807 @@
1
+ import * as path from "node:path";
2
+ import type {
3
+ ExtensionAPI,
4
+ ExtensionContext,
5
+ ToolResultEventResult,
6
+ } from "@oh-my-pi/pi-coding-agent";
7
+ import {
8
+ findProjectJscpdConfig,
9
+ type JscpdProjectConfig,
10
+ } from "./config-loader";
11
+ import { DuplicateDetectorCoordinator } from "./coordinator";
12
+ import { DuplicateLedger } from "./duplicate-ledger";
13
+ import {
14
+ type BaselineStatus,
15
+ createIgnoreFilter,
16
+ isGeneratedContent,
17
+ MAX_INDEXED_FILES,
18
+ } from "./jscpd-engine";
19
+ import { isProjectEnabled, setProjectEnabled } from "./project-state";
20
+ import {
21
+ DuplicateNotificationComponent,
22
+ type DuplicateNotificationData,
23
+ DuplicateStatusComponent,
24
+ type DuplicateStatusData,
25
+ type ThemeLike,
26
+ } from "./tui-notification";
27
+
28
+ export interface DuplicateDetectorConfig {
29
+ minLines: number;
30
+ minTokens: number;
31
+ maxLines?: number;
32
+ maxIndexedFiles?: number;
33
+ checkOnMutation: boolean;
34
+ reminderMode: "in-band" | "steer" | "none";
35
+ ignorePatterns: string[];
36
+ ignoreTests: boolean;
37
+ customTestPatterns?: string[];
38
+ excludeTestPatterns?: string[];
39
+ formatsExts?: Record<string, string[]>;
40
+ configSource?: string;
41
+ }
42
+
43
+ const DEFAULT_CONFIG: DuplicateDetectorConfig = {
44
+ minLines: 5,
45
+ minTokens: 40,
46
+ checkOnMutation: true,
47
+ reminderMode: "steer",
48
+ ignorePatterns: [],
49
+ ignoreTests: true,
50
+ };
51
+
52
+ /**
53
+ * Resolves user settings from configuration context merged with project-level jscpd configuration.
54
+ */
55
+ export function resolveConfig(
56
+ rawSettings?: Record<string, unknown>,
57
+ projectConfig?: JscpdProjectConfig | null,
58
+ ): DuplicateDetectorConfig {
59
+ const baseMinLines = projectConfig?.minLines ?? DEFAULT_CONFIG.minLines;
60
+ const baseMinTokens = projectConfig?.minTokens ?? DEFAULT_CONFIG.minTokens;
61
+ const baseMaxLines = projectConfig?.maxLines;
62
+ const baseMaxIndexedFiles = projectConfig?.maxIndexedFiles;
63
+ const baseFormatsExts = projectConfig?.formatsExts;
64
+ const baseIgnoreTests =
65
+ projectConfig?.ignoreTests ?? DEFAULT_CONFIG.ignoreTests;
66
+ const baseCustomTestPatterns = projectConfig?.customTestPatterns;
67
+ const baseExcludeTestPatterns = projectConfig?.excludeTestPatterns;
68
+ const projectIgnores = projectConfig?.ignore ?? [];
69
+
70
+ let configSource: string | undefined;
71
+ if (projectConfig?.sourcePath) {
72
+ configSource =
73
+ projectConfig.sourceType === "package.json"
74
+ ? `${projectConfig.sourcePath}#jscpd`
75
+ : projectConfig.sourcePath;
76
+ }
77
+
78
+ if (!rawSettings) {
79
+ return {
80
+ minLines: baseMinLines,
81
+ minTokens: baseMinTokens,
82
+ maxLines: baseMaxLines,
83
+ maxIndexedFiles: baseMaxIndexedFiles,
84
+ checkOnMutation: DEFAULT_CONFIG.checkOnMutation,
85
+ reminderMode: DEFAULT_CONFIG.reminderMode,
86
+ ignorePatterns: projectIgnores,
87
+ ignoreTests: baseIgnoreTests,
88
+ customTestPatterns: baseCustomTestPatterns,
89
+ excludeTestPatterns: baseExcludeTestPatterns,
90
+ formatsExts: baseFormatsExts,
91
+ configSource,
92
+ };
93
+ }
94
+
95
+ const minLines =
96
+ typeof rawSettings.minLines === "number"
97
+ ? Math.max(3, rawSettings.minLines)
98
+ : baseMinLines;
99
+ const minTokens =
100
+ typeof rawSettings.minTokens === "number"
101
+ ? Math.max(10, rawSettings.minTokens)
102
+ : baseMinTokens;
103
+ const checkOnMutation =
104
+ typeof rawSettings.checkOnMutation === "boolean"
105
+ ? rawSettings.checkOnMutation
106
+ : DEFAULT_CONFIG.checkOnMutation;
107
+
108
+ let maxIndexedFiles: number | undefined = baseMaxIndexedFiles;
109
+ if (
110
+ typeof rawSettings.maxIndexedFiles === "number" &&
111
+ !Number.isNaN(rawSettings.maxIndexedFiles) &&
112
+ rawSettings.maxIndexedFiles > 0
113
+ ) {
114
+ maxIndexedFiles = Math.floor(rawSettings.maxIndexedFiles);
115
+ } else if (typeof rawSettings.maxIndexedFiles === "string") {
116
+ const parsed = Number.parseInt(rawSettings.maxIndexedFiles, 10);
117
+ if (!Number.isNaN(parsed) && parsed > 0) {
118
+ maxIndexedFiles = parsed;
119
+ }
120
+ }
121
+
122
+ const reminderMode =
123
+ rawSettings.reminderMode === "steer" ||
124
+ rawSettings.reminderMode === "none" ||
125
+ rawSettings.reminderMode === "in-band"
126
+ ? rawSettings.reminderMode
127
+ : DEFAULT_CONFIG.reminderMode;
128
+
129
+ let userIgnores: string[] = [];
130
+ if (typeof rawSettings.ignorePatterns === "string") {
131
+ userIgnores = rawSettings.ignorePatterns
132
+ .split(",")
133
+ .map((p) => p.trim())
134
+ .filter((p) => p.length > 0);
135
+ } else if (Array.isArray(rawSettings.ignorePatterns)) {
136
+ userIgnores = rawSettings.ignorePatterns
137
+ .map(String)
138
+ .map((p) => p.trim())
139
+ .filter(Boolean);
140
+ }
141
+
142
+ let ignoreTests = baseIgnoreTests;
143
+ if (typeof rawSettings.ignoreTests === "boolean") {
144
+ ignoreTests = rawSettings.ignoreTests;
145
+ } else if (typeof rawSettings.ignoreTests === "string") {
146
+ if (rawSettings.ignoreTests.toLowerCase() === "false") {
147
+ ignoreTests = false;
148
+ } else if (rawSettings.ignoreTests.toLowerCase() === "true") {
149
+ ignoreTests = true;
150
+ }
151
+ }
152
+
153
+ let userCustomTests: string[] = [];
154
+ if (typeof rawSettings.customTestPatterns === "string") {
155
+ userCustomTests = rawSettings.customTestPatterns
156
+ .split(",")
157
+ .map((p) => p.trim())
158
+ .filter((p) => p.length > 0);
159
+ } else if (Array.isArray(rawSettings.customTestPatterns)) {
160
+ userCustomTests = rawSettings.customTestPatterns
161
+ .map(String)
162
+ .map((p) => p.trim())
163
+ .filter(Boolean);
164
+ }
165
+ const mergedCustomTests = Array.from(
166
+ new Set([...(baseCustomTestPatterns ?? []), ...userCustomTests]),
167
+ );
168
+
169
+ let userExcludeTests: string[] = [];
170
+ if (typeof rawSettings.excludeTestPatterns === "string") {
171
+ userExcludeTests = rawSettings.excludeTestPatterns
172
+ .split(",")
173
+ .map((p) => p.trim())
174
+ .filter((p) => p.length > 0);
175
+ } else if (Array.isArray(rawSettings.excludeTestPatterns)) {
176
+ userExcludeTests = rawSettings.excludeTestPatterns
177
+ .map(String)
178
+ .map((p) => p.trim())
179
+ .filter(Boolean);
180
+ }
181
+ const mergedExcludeTests = Array.from(
182
+ new Set([...(baseExcludeTestPatterns ?? []), ...userExcludeTests]),
183
+ );
184
+
185
+ const mergedIgnores = Array.from(
186
+ new Set([...projectIgnores, ...userIgnores]),
187
+ );
188
+
189
+ return {
190
+ minLines,
191
+ minTokens,
192
+ maxLines: baseMaxLines,
193
+ maxIndexedFiles,
194
+ checkOnMutation,
195
+ reminderMode,
196
+ ignorePatterns: mergedIgnores,
197
+ ignoreTests,
198
+ customTestPatterns:
199
+ mergedCustomTests.length > 0 ? mergedCustomTests : undefined,
200
+ excludeTestPatterns:
201
+ mergedExcludeTests.length > 0 ? mergedExcludeTests : undefined,
202
+ formatsExts: baseFormatsExts,
203
+ configSource,
204
+ };
205
+ }
206
+
207
+ function extractSettingsObject(
208
+ event: unknown,
209
+ ctx: unknown,
210
+ ): Record<string, unknown> | undefined {
211
+ if (
212
+ event &&
213
+ typeof event === "object" &&
214
+ "settings" in event &&
215
+ event.settings &&
216
+ typeof event.settings === "object"
217
+ ) {
218
+ return event.settings as Record<string, unknown>;
219
+ }
220
+ if (
221
+ ctx &&
222
+ typeof ctx === "object" &&
223
+ "settings" in ctx &&
224
+ ctx.settings &&
225
+ typeof ctx.settings === "object"
226
+ ) {
227
+ return ctx.settings as Record<string, unknown>;
228
+ }
229
+ return undefined;
230
+ }
231
+
232
+ export function formatBaselineMessage(
233
+ status: BaselineStatus,
234
+ count: number,
235
+ cachedCount?: number,
236
+ maxIndexedFiles?: number,
237
+ ): string {
238
+ if (status === "disabled") {
239
+ return "Duplicate detector: Disabled for this project (use '/duplicates on' to enable)";
240
+ }
241
+
242
+ if (status === "skipped_not_git") {
243
+ return "Duplicate detector: Baseline skipped (not a Git repository; mutation checks active)";
244
+ }
245
+ const fileWord = count === 1 ? "file" : "files";
246
+ const formattedCount = count.toLocaleString();
247
+
248
+ let cacheDetail = "";
249
+ if (cachedCount !== undefined && count > 0) {
250
+ if (cachedCount === count) {
251
+ cacheDetail = ", cached";
252
+ } else if (cachedCount === 0) {
253
+ cacheDetail = ", uncached";
254
+ } else {
255
+ cacheDetail = `, ${cachedCount.toLocaleString()} cached`;
256
+ }
257
+ }
258
+
259
+ if (status === "complete") {
260
+ const gitLabel = count === 1 ? "Git file" : "Git files";
261
+ return `Duplicate detector: Ready (${formattedCount} ${gitLabel} indexed${cacheDetail})`;
262
+ }
263
+
264
+ if (status === "capped_file_count") {
265
+ const limit = (maxIndexedFiles ?? MAX_INDEXED_FILES).toLocaleString();
266
+ return `Duplicate detector: Ready (${formattedCount} ${fileWord} indexed${cacheDetail}, capped at ${limit} file limit)`;
267
+ }
268
+
269
+ if (status === "capped_source_bytes") {
270
+ return `Duplicate detector: Ready (${formattedCount} ${fileWord} indexed${cacheDetail}, capped at 64 MB limit)`;
271
+ }
272
+
273
+ return "";
274
+ }
275
+
276
+ function notifyBaselineStatus(
277
+ pi: ExtensionAPI,
278
+ ctx:
279
+ | {
280
+ ui?: {
281
+ notify: (msg: string, type?: "info" | "warning" | "error") => void;
282
+ };
283
+ }
284
+ | undefined,
285
+ status: BaselineStatus,
286
+ count: number,
287
+ cachedCount?: number,
288
+ maxIndexedFiles?: number,
289
+ ): void {
290
+ const message = formatBaselineMessage(
291
+ status,
292
+ count,
293
+ cachedCount,
294
+ maxIndexedFiles,
295
+ );
296
+ if (!message) return;
297
+
298
+ const level: "info" | "warning" =
299
+ status === "capped_file_count" || status === "capped_source_bytes"
300
+ ? "warning"
301
+ : "info";
302
+
303
+ // 1. Send transient TUI toast if UI is active
304
+ ctx?.ui?.notify?.(message, level);
305
+
306
+ // 2. Send persistent transcript notification into the chat feed
307
+ pi.sendMessage(
308
+ {
309
+ customType: "duplicate-detector-status",
310
+ content: message,
311
+ display: true,
312
+ attribution: "user",
313
+ details: {
314
+ status,
315
+ count,
316
+ cachedCount,
317
+ content: message,
318
+ },
319
+ },
320
+ { triggerTurn: false },
321
+ );
322
+ }
323
+
324
+ /**
325
+ * Main extension factory for oh-my-pi duplicate detector plugin powered by jscpd.
326
+ */
327
+ export default function duplicateDetectorExtension(pi: ExtensionAPI): void {
328
+ pi.setLabel("Duplicate Detector");
329
+
330
+ let isEnabledForProject = true;
331
+ let activeRawSettings: Record<string, unknown> | undefined;
332
+ let config: DuplicateDetectorConfig = { ...DEFAULT_CONFIG };
333
+ let currentCwd: string = process.cwd();
334
+ let lastCtx: ExtensionContext | undefined;
335
+ const ledger = new DuplicateLedger();
336
+ const fileRevisions = new Map<string, number>();
337
+ const coordinator = new DuplicateDetectorCoordinator();
338
+ let workerFailureNotified = false;
339
+ const notifyWorkerFailure = (error: unknown): void => {
340
+ if (workerFailureNotified || !lastCtx) return;
341
+ workerFailureNotified = true;
342
+ const reason = error instanceof Error ? error.message : String(error);
343
+ const message = `Duplicate detector: Background worker failed (${reason})`;
344
+
345
+ lastCtx.ui?.notify?.(message, "error");
346
+ pi.sendMessage(
347
+ {
348
+ customType: "duplicate-detector-status",
349
+ content: message,
350
+ display: true,
351
+ attribution: "user",
352
+ details: {
353
+ status: "failed",
354
+ error: reason,
355
+ content: message,
356
+ },
357
+ },
358
+ { triggerTurn: false },
359
+ );
360
+ };
361
+
362
+ // Wire coordinator event listeners
363
+ coordinator.on("progress", (payload) => {
364
+ pi.logger.debug("Duplicate detector indexing progress", {
365
+ ...payload,
366
+ });
367
+ });
368
+
369
+ coordinator.on("complete", (payload) => {
370
+ if (!isEnabledForProject) return;
371
+ const status: BaselineStatus = payload.status ?? "complete";
372
+ if (status === "failed") {
373
+ notifyWorkerFailure(payload.error ?? "Baseline indexing failed");
374
+ return;
375
+ }
376
+ workerFailureNotified = false;
377
+ notifyBaselineStatus(
378
+ pi,
379
+ lastCtx,
380
+ status,
381
+ payload.indexedCount,
382
+ payload.cachedCount,
383
+ config.maxIndexedFiles,
384
+ );
385
+ });
386
+
387
+ coordinator.on("status", (payload) => {
388
+ pi.logger.debug("Duplicate detector worker status", {
389
+ ...payload,
390
+ });
391
+ if (payload.status === "error") {
392
+ notifyWorkerFailure(payload.message ?? "Unknown worker error");
393
+ }
394
+ });
395
+
396
+ coordinator.on("error", (err) => {
397
+ pi.logger.warn("Duplicate detector coordinator error", {
398
+ error: err instanceof Error ? err.message : String(err),
399
+ });
400
+ notifyWorkerFailure(err);
401
+ });
402
+
403
+ coordinator.on("lateFinding", async ({ clone }) => {
404
+ if (!isEnabledForProject) return;
405
+ if (!config.checkOnMutation || config.reminderMode === "none") return;
406
+ const sourceA = clone.duplicationA.sourceId;
407
+ const sourceB = clone.duplicationB.sourceId;
408
+
409
+ const relA = (
410
+ path.isAbsolute(sourceA) && currentCwd
411
+ ? path.relative(currentCwd, sourceA)
412
+ : sourceA
413
+ ).replace(/\\/g, "/");
414
+
415
+ const relB = (
416
+ path.isAbsolute(sourceB) && currentCwd
417
+ ? path.relative(currentCwd, sourceB)
418
+ : sourceB
419
+ ).replace(/\\/g, "/");
420
+
421
+ // Determine if either file is one that was recently mutated in the active session
422
+ const targetRel = fileRevisions.has(relA)
423
+ ? relA
424
+ : fileRevisions.has(relB)
425
+ ? relB
426
+ : undefined;
427
+
428
+ if (!targetRel) return;
429
+
430
+ const freshClones = ledger.filterFreshClones(targetRel, [clone]);
431
+ if (freshClones.length > 0) {
432
+ const fullPath = path.isAbsolute(targetRel)
433
+ ? targetRel
434
+ : path.join(currentCwd, targetRel);
435
+ let content: string | undefined;
436
+ try {
437
+ const file = Bun.file(fullPath);
438
+ if (await file.exists()) {
439
+ content = await file.text();
440
+ }
441
+ } catch {}
442
+
443
+ const reminder = ledger.formatReminder(freshClones, targetRel, content);
444
+ pi.logger.info("Late clone finding surfaced for file mutation", {
445
+ file: targetRel,
446
+ cloneCount: freshClones.length,
447
+ });
448
+
449
+ if (
450
+ config.reminderMode === "steer" ||
451
+ config.reminderMode === "in-band"
452
+ ) {
453
+ pi.sendMessage(
454
+ {
455
+ customType: "duplicate-detector-warning",
456
+ content: reminder,
457
+ display: true,
458
+ attribution: "user",
459
+ details: {
460
+ filePath: targetRel,
461
+ clones: freshClones,
462
+ content,
463
+ },
464
+ },
465
+ { deliverAs: "steer" },
466
+ );
467
+ }
468
+ }
469
+ });
470
+
471
+ // Session lifecycle: reset on switch and branch
472
+ pi.on("session_switch", async (event, ctx) => {
473
+ ledger.clear();
474
+ fileRevisions.clear();
475
+ workerFailureNotified = false;
476
+ if (ctx?.cwd) {
477
+ currentCwd = ctx.cwd;
478
+ }
479
+ lastCtx = ctx as ExtensionContext;
480
+
481
+ activeRawSettings = extractSettingsObject(event, ctx);
482
+ const projectConfig = ctx?.cwd
483
+ ? await findProjectJscpdConfig(ctx.cwd)
484
+ : null;
485
+ config = resolveConfig(activeRawSettings, projectConfig);
486
+
487
+ if (ctx?.cwd) {
488
+ isEnabledForProject = await isProjectEnabled(ctx.cwd);
489
+ if (!isEnabledForProject) {
490
+ pi.logger.info("Duplicate detector is disabled for this project", {
491
+ cwd: ctx.cwd,
492
+ });
493
+ notifyBaselineStatus(pi, lastCtx, "disabled", 0);
494
+ return;
495
+ }
496
+
497
+ try {
498
+ await coordinator.openWorkspace(ctx.cwd, config);
499
+ } catch (err) {
500
+ notifyWorkerFailure(err);
501
+ }
502
+ }
503
+ });
504
+
505
+ pi.on("session_branch", async () => {
506
+ ledger.clear();
507
+ fileRevisions.clear();
508
+ });
509
+
510
+ pi.on("session_shutdown", async () => {
511
+ await coordinator.dispose();
512
+ });
513
+
514
+ if (typeof process !== "undefined" && typeof process.on === "function") {
515
+ process.once("beforeExit", () => {
516
+ coordinator.dispose().catch(() => {});
517
+ });
518
+ }
519
+
520
+ // Initialize repository index in background on session start (non-blocking)
521
+ pi.on("session_start", async (event, ctx) => {
522
+ currentCwd = ctx.cwd;
523
+ lastCtx = ctx as ExtensionContext;
524
+ fileRevisions.clear();
525
+ workerFailureNotified = false;
526
+
527
+ pi.logger.debug("Duplicate detector initializing workspace index", {
528
+ cwd: ctx.cwd,
529
+ });
530
+
531
+ activeRawSettings = extractSettingsObject(event, ctx);
532
+ const projectConfig = await findProjectJscpdConfig(ctx.cwd);
533
+ config = resolveConfig(activeRawSettings, projectConfig);
534
+
535
+ if (config.configSource) {
536
+ pi.logger.info("Duplicate detector loaded project configuration", {
537
+ source: config.configSource,
538
+ minLines: config.minLines,
539
+ minTokens: config.minTokens,
540
+ ignoreCount: config.ignorePatterns.length,
541
+ });
542
+ }
543
+
544
+ isEnabledForProject = await isProjectEnabled(ctx.cwd);
545
+ if (!isEnabledForProject) {
546
+ pi.logger.info("Duplicate detector is disabled for this project", {
547
+ cwd: ctx.cwd,
548
+ });
549
+ notifyBaselineStatus(pi, lastCtx, "disabled", 0);
550
+ return;
551
+ }
552
+
553
+ try {
554
+ await coordinator.openWorkspace(ctx.cwd, config);
555
+ } catch (err) {
556
+ pi.logger.warn("Duplicate detector background indexing failed", {
557
+ error: err instanceof Error ? err.message : String(err),
558
+ });
559
+ notifyWorkerFailure(err);
560
+ }
561
+ });
562
+
563
+ // Intercept write and edit tool executions to detect clones in newly added/modified code
564
+ pi.on(
565
+ "tool_result",
566
+ async (event, ctx): Promise<ToolResultEventResult | void> => {
567
+ if (event.isError) return;
568
+ if (!isEnabledForProject) return;
569
+ if (!config.checkOnMutation) return;
570
+ if (config.reminderMode === "none") return;
571
+ if (event.toolName !== "write" && event.toolName !== "edit") return;
572
+
573
+ const input = event.input as { path?: string };
574
+ const rawPath = input?.path;
575
+ if (!rawPath || typeof rawPath !== "string") return;
576
+
577
+ // Skip internal protocol URLs (e.g. xd://, local://)
578
+ if (rawPath.includes("://")) return;
579
+
580
+ const fullPath = path.isAbsolute(rawPath)
581
+ ? path.resolve(rawPath)
582
+ : path.resolve(ctx.cwd, rawPath);
583
+ const relPath = path.relative(ctx.cwd, fullPath);
584
+ const normalizedRelPath = relPath.replace(/\\/g, "/");
585
+
586
+ // If the mutated file is outside the workspace root or invalid, skip it
587
+ if (
588
+ !normalizedRelPath ||
589
+ normalizedRelPath === ".." ||
590
+ normalizedRelPath.startsWith("../") ||
591
+ path.isAbsolute(normalizedRelPath)
592
+ ) {
593
+ return;
594
+ }
595
+
596
+ try {
597
+ // Skip ignored files (matching ignore patterns, noise files, or test files)
598
+ const ignoreFilter = createIgnoreFilter(config.ignorePatterns, {
599
+ ignoreTests: config.ignoreTests,
600
+ customTestPatterns: config.customTestPatterns,
601
+ excludeTestPatterns: config.excludeTestPatterns,
602
+ });
603
+ if (ignoreFilter(normalizedRelPath)) return;
604
+ const file = Bun.file(fullPath);
605
+ if (!(await file.exists())) return;
606
+
607
+ const content = await file.text();
608
+
609
+ // Skip generated files
610
+ if (isGeneratedContent(content)) return;
611
+
612
+ const revision = (fileRevisions.get(normalizedRelPath) ?? 0) + 1;
613
+ fileRevisions.set(normalizedRelPath, revision);
614
+
615
+ const { clones } = await coordinator.checkAndUpdate(
616
+ fullPath,
617
+ content,
618
+ revision,
619
+ );
620
+ const freshClones = ledger.filterFreshClones(normalizedRelPath, clones);
621
+
622
+ if (freshClones.length > 0) {
623
+ const fullReminder = ledger.formatReminder(
624
+ freshClones,
625
+ normalizedRelPath,
626
+ content,
627
+ undefined,
628
+ { maxClones: 0, maxSnippetLines: 0 },
629
+ );
630
+
631
+ let artifactId: string | undefined;
632
+ const fullBytes = Buffer.byteLength(fullReminder, "utf-8");
633
+ const needsTruncation =
634
+ freshClones.length > 4 || fullBytes > 8 * 1024;
635
+
636
+ if (needsTruncation && ctx?.sessionManager?.saveArtifact) {
637
+ try {
638
+ artifactId = await ctx.sessionManager.saveArtifact(
639
+ fullReminder,
640
+ "duplicates",
641
+ );
642
+ } catch (err) {
643
+ pi.logger.warn(
644
+ "Failed to persist mutation duplicate warning to session artifact",
645
+ {
646
+ error: err instanceof Error ? err.message : String(err),
647
+ },
648
+ );
649
+ }
650
+ }
651
+
652
+ const reminder = ledger.formatReminder(
653
+ freshClones,
654
+ normalizedRelPath,
655
+ content,
656
+ undefined,
657
+ {
658
+ maxClones: 4,
659
+ maxSnippetLines: 8,
660
+ artifactId,
661
+ },
662
+ );
663
+
664
+ pi.logger.info("Duplicates detected on file mutation", {
665
+ file: normalizedRelPath,
666
+ count: freshClones.length,
667
+ artifactId,
668
+ });
669
+
670
+ if (config.reminderMode === "steer") {
671
+ pi.sendMessage(
672
+ {
673
+ customType: "duplicate-detector-warning",
674
+ content: reminder,
675
+ display: true,
676
+ attribution: "user",
677
+ details: {
678
+ filePath: normalizedRelPath,
679
+ clones: freshClones,
680
+ content,
681
+ artifactId,
682
+ },
683
+ },
684
+ { deliverAs: "steer" },
685
+ );
686
+ return;
687
+ }
688
+
689
+ // Prepend <system-reminder> to tool result content for in-band display
690
+ const originalContent = Array.isArray(event.content)
691
+ ? (event.content as Array<{ type: "text"; text: string }>)
692
+ : [{ type: "text" as const, text: String(event.content ?? "") }];
693
+
694
+ const modifiedContent = [
695
+ { type: "text" as const, text: reminder },
696
+ ...originalContent,
697
+ ];
698
+
699
+ return {
700
+ content: modifiedContent,
701
+ };
702
+ }
703
+ } catch (err) {
704
+ pi.logger.warn("Failed checking duplicates for mutated file", {
705
+ file: normalizedRelPath,
706
+ error: err instanceof Error ? err.message : String(err),
707
+ });
708
+ notifyWorkerFailure(err);
709
+ }
710
+ },
711
+ );
712
+ // Register /duplicates slash command
713
+ pi.registerCommand("duplicates", {
714
+ description:
715
+ "Toggle duplicate detector on or off for this project (/duplicates on|off)",
716
+ getArgumentCompletions: (prefix) => {
717
+ const options = ["on", "off", "status"];
718
+ return options
719
+ .filter((opt) => opt.startsWith(prefix.toLowerCase()))
720
+ .map((label) => ({
721
+ value: label,
722
+ label,
723
+ description:
724
+ label === "on"
725
+ ? "Enable duplicate detection for this project"
726
+ : label === "off"
727
+ ? "Disable duplicate detection for this project"
728
+ : "Check duplicate detection status for this project",
729
+ }));
730
+ },
731
+ handler: async (args, ctx) => {
732
+ const action = args.trim().toLowerCase();
733
+ if (action === "on") {
734
+ await setProjectEnabled(ctx.cwd, true);
735
+ isEnabledForProject = true;
736
+ ctx.ui.notify("Duplicate detector enabled for this project.", "info");
737
+ try {
738
+ await coordinator.openWorkspace(ctx.cwd, config);
739
+ } catch (err) {
740
+ notifyWorkerFailure(err);
741
+ }
742
+ } else if (action === "off") {
743
+ await setProjectEnabled(ctx.cwd, false);
744
+ isEnabledForProject = false;
745
+ ledger.clear();
746
+ fileRevisions.clear();
747
+ ctx.ui.notify("Duplicate detector disabled for this project.", "info");
748
+ notifyBaselineStatus(pi, ctx as ExtensionContext, "disabled", 0);
749
+ } else if (action === "status" || action === "") {
750
+ const enabled = await isProjectEnabled(ctx.cwd);
751
+ ctx.ui.notify(
752
+ `Duplicate detector is currently ${enabled ? "enabled" : "disabled"} for this project. Usage: /duplicates on|off`,
753
+ "info",
754
+ );
755
+ } else {
756
+ ctx.ui.notify(
757
+ `Unknown argument "${args.trim()}". Usage: /duplicates on|off`,
758
+ "error",
759
+ );
760
+ }
761
+ },
762
+ });
763
+
764
+ // Register TTSR-styled message renderers for duplicate alerts and reports
765
+ if (typeof pi.registerMessageRenderer === "function") {
766
+ pi.registerMessageRenderer<DuplicateNotificationData>(
767
+ "duplicate-detector-warning",
768
+ (message, options, theme) => {
769
+ const data = message.details || {
770
+ content:
771
+ typeof message.content === "string" ? message.content : undefined,
772
+ };
773
+ return new DuplicateNotificationComponent(
774
+ data,
775
+ options?.expanded ?? false,
776
+ theme as ThemeLike,
777
+ );
778
+ },
779
+ );
780
+
781
+ pi.registerMessageRenderer<DuplicateNotificationData>(
782
+ "duplicate-detector-report",
783
+ (message, options, theme) => {
784
+ const data = message.details || {
785
+ content:
786
+ typeof message.content === "string" ? message.content : undefined,
787
+ };
788
+ return new DuplicateNotificationComponent(
789
+ data,
790
+ options?.expanded ?? false,
791
+ theme as ThemeLike,
792
+ );
793
+ },
794
+ );
795
+
796
+ pi.registerMessageRenderer<DuplicateStatusData>(
797
+ "duplicate-detector-status",
798
+ (message, _options, theme) => {
799
+ const data = message.details || {
800
+ content:
801
+ typeof message.content === "string" ? message.content : undefined,
802
+ };
803
+ return new DuplicateStatusComponent(data, theme as ThemeLike);
804
+ },
805
+ );
806
+ }
807
+ }