1010 *
1111 * Hooks into `agent_end` to propose running `/simplify` when source files
1212 * were modified during the agent's turn. Shows a timed confirmation that
13- * auto-accepts after 5 seconds.
13+ * auto-accepts after 5 seconds. Files are only proposed once — after
14+ * simplification their content hashes are persisted and checked before
15+ * any subsequent proposal.
1416 *
1517 * To add a new language:
1618 * 1. Create `skills/<lang>-code-simplifier/SKILL.md`
1719 * 2. Add one entry to FILE_EXTENSIONS below
1820 */
1921
2022import type { ExtensionAPI , ExtensionContext } from "@mariozechner/pi-coding-agent" ;
21- import { existsSync , readFileSync , readdirSync } from "node:fs" ;
23+ import { createHash } from "node:crypto" ;
24+ import { existsSync , mkdirSync , readFileSync , readdirSync , writeFileSync } from "node:fs" ;
2225import { dirname , extname , join , resolve } from "node:path" ;
2326import { fileURLToPath } from "node:url" ;
2427
@@ -142,6 +145,16 @@ function filterByLanguage(files: string[], lang: string): string[] {
142145 return files . filter ( ( f ) => FILE_EXTENSIONS [ fileExtension ( f ) ] === lang ) ;
143146}
144147
148+ /** SHA-256 hash of a file's content. Returns null if the file can't be read. */
149+ function hashFile ( filePath : string ) : string | null {
150+ try {
151+ const content = readFileSync ( filePath ) ;
152+ return createHash ( "sha256" ) . update ( content ) . digest ( "hex" ) ;
153+ } catch {
154+ return null ;
155+ }
156+ }
157+
145158// ---------------------------------------------------------------------------
146159// Settings
147160// ---------------------------------------------------------------------------
@@ -180,6 +193,56 @@ function readSettings(): SimplifySettings {
180193 }
181194}
182195
196+ // ---------------------------------------------------------------------------
197+ // Persisted simplification hashes
198+ // ---------------------------------------------------------------------------
199+
200+ /** Keyed by cwd so different projects don't collide. */
201+ type HashStore = Record < string , Record < string , string > > ;
202+
203+ /** Resolve the path to the hash store file. Returns null when unconfigured. */
204+ function hashStorePath ( ) : string | null {
205+ const configDir = process . env . PI_CODING_AGENT_DIR ;
206+ if ( ! configDir ) return null ;
207+ return join ( configDir , "simplify-hashes.json" ) ;
208+ }
209+
210+ /** Load persisted hashes for the current working directory. */
211+ function loadHashes ( ) : Map < string , string > {
212+ const path = hashStorePath ( ) ;
213+ if ( ! path || ! existsSync ( path ) ) return new Map ( ) ;
214+
215+ try {
216+ const store = JSON . parse ( readFileSync ( path , "utf-8" ) ) as HashStore ;
217+ const project = store [ process . cwd ( ) ] ;
218+ if ( ! project || typeof project !== "object" ) return new Map ( ) ;
219+ return new Map ( Object . entries ( project ) ) ;
220+ } catch {
221+ return new Map ( ) ;
222+ }
223+ }
224+
225+ /** Persist hashes for the current working directory. */
226+ function saveHashes ( hashes : Map < string , string > ) : void {
227+ const path = hashStorePath ( ) ;
228+ if ( ! path ) return ;
229+
230+ let store : HashStore = { } ;
231+ try {
232+ if ( existsSync ( path ) ) {
233+ store = JSON . parse ( readFileSync ( path , "utf-8" ) ) as HashStore ;
234+ }
235+ } catch {
236+ store = { } ;
237+ }
238+
239+ store [ process . cwd ( ) ] = Object . fromEntries ( hashes ) ;
240+
241+ const dir = dirname ( path ) ;
242+ if ( ! existsSync ( dir ) ) mkdirSync ( dir , { recursive : true } ) ;
243+ writeFileSync ( path , JSON . stringify ( store , null , "\t" ) , "utf-8" ) ;
244+ }
245+
183246// ---------------------------------------------------------------------------
184247// Git helpers
185248// ---------------------------------------------------------------------------
@@ -355,7 +418,7 @@ async function triggerSimplify(
355418 const relevantFiles = filterByLanguage ( files , lang ) ;
356419 notify ( `Simplifying ${ relevantFiles . length } ${ lang . toUpperCase ( ) } file(s)…` , "info" ) ;
357420
358- simplifyPending = true ;
421+ pendingSimplifyFiles = relevantFiles ;
359422 pi . sendUserMessage ( buildPrompt ( skillContent , relevantFiles , options . extraInstructions ) ) ;
360423 return true ;
361424}
@@ -404,17 +467,22 @@ function wasAborted(event: unknown): boolean {
404467let statsAtStart : Map < string , FileStats > = new Map ( ) ;
405468
406469/**
407- * Whether simplify has already been proposed since the last user-initiated
408- * prompt. Prevents re-proposing after the simplification run itself
409- * modifies files. Only reset when the user types a new prompt.
470+ * Content hashes of files after their last simplification pass.
471+ *
472+ * Loaded from `$PI_CODING_AGENT_DIR/simplify-hashes.json` on startup,
473+ * updated and persisted after each simplification run. Keyed by relative
474+ * file path so proposals are suppressed until the file actually changes.
410475 */
411- let hasProposedSimplify = false ;
476+ const simplifiedHashes : Map < string , string > = loadHashes ( ) ;
412477
413478/**
414- * Set by `triggerSimplify` before `sendUserMessage` so `before_agent_start`
415- * can distinguish programmatic turns from user-initiated ones.
479+ * Files being simplified in the current agent turn.
480+ *
481+ * Set by `triggerSimplify` before `sendUserMessage`. When `agent_end`
482+ * sees this is non-null, it hashes the files, persists them, and skips
483+ * the proposal.
416484 */
417- let simplifyPending = false ;
485+ let pendingSimplifyFiles : string [ ] | null = null ;
418486
419487/**
420488 * Files queued by the `agent_end` auto-simplify confirmation.
@@ -433,10 +501,6 @@ export default function simplifyExtension(pi: ExtensionAPI) {
433501 // ── Snapshot dirty files at turn start ──────────────────────────
434502
435503 pi . on ( "before_agent_start" , async ( ) => {
436- if ( ! simplifyPending ) {
437- hasProposedSimplify = false ;
438- }
439- simplifyPending = false ;
440504 statsAtStart = await snapshotFileStats ( pi ) ;
441505 } ) ;
442506
@@ -472,7 +536,18 @@ export default function simplifyExtension(pi: ExtensionAPI) {
472536 // ── Auto-simplify proposal after agent turn ─────────────────────
473537
474538 pi . on ( "agent_end" , async ( event : unknown , ctx : ExtensionContext ) => {
475- if ( ! ctx . hasUI || hasProposedSimplify ) return ;
539+ // After a simplify turn, hash the output files and persist.
540+ if ( pendingSimplifyFiles ) {
541+ for ( const file of pendingSimplifyFiles ) {
542+ const hash = hashFile ( file ) ;
543+ if ( hash ) simplifiedHashes . set ( file , hash ) ;
544+ }
545+ pendingSimplifyFiles = null ;
546+ saveHashes ( simplifiedHashes ) ;
547+ return ;
548+ }
549+
550+ if ( ! ctx . hasUI ) return ;
476551 if ( wasAborted ( event ) ) return ;
477552
478553 const statsAtEnd = await snapshotFileStats ( pi ) ;
@@ -488,27 +563,34 @@ export default function simplifyExtension(pi: ExtensionAPI) {
488563 const relevantFiles = filterByLanguage ( sourceFiles , lang ) ;
489564 if ( relevantFiles . length === 0 ) return ;
490565
566+ // Skip files whose content hasn't changed since last simplification.
567+ const unsimplified = relevantFiles . filter ( ( f ) => {
568+ const currentHash = hashFile ( f ) ;
569+ if ( ! currentHash ) return false ;
570+ const storedHash = simplifiedHashes . get ( f ) ;
571+ return ! storedHash || currentHash !== storedHash ;
572+ } ) ;
573+ if ( unsimplified . length === 0 ) return ;
574+
491575 const { minChangedLines } = readSettings ( ) ;
492576 if ( minChangedLines > 0 ) {
493577 let totalDelta = 0 ;
494- for ( const file of relevantFiles ) {
578+ for ( const file of unsimplified ) {
495579 totalDelta += modified . get ( file ) ?? 0 ;
496580 }
497581 if ( totalDelta < minChangedLines ) return ;
498582 }
499583
500- hasProposedSimplify = true ;
501-
502584 const confirmed = await timedConfirm ( ctx , {
503585 title : "Simplify code" ,
504- message : buildConfirmMessage ( relevantFiles , lang ) ,
586+ message : buildConfirmMessage ( unsimplified , lang ) ,
505587 seconds : 5 ,
506588 defaultValue : true ,
507589 } ) ;
508590
509591 if ( ! confirmed ) return ;
510592
511- pendingAutoSimplifyFiles = sourceFiles ;
593+ pendingAutoSimplifyFiles = unsimplified ;
512594 pi . sendUserMessage ( "/simplify" ) ;
513595 } ) ;
514596}
0 commit comments