pi-code 1.0.5 → 1.0.7

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.
@@ -159,34 +159,60 @@ function readImport(target: string, fromDir: string, home: string, allowedRoots:
159
159
  }
160
160
  }
161
161
 
162
- /**
163
- * Collect the contents of every file transitively imported via `@path`, in
164
- * discovery order. Imports are resolved through symlinks and kept within
165
- * `allowedRoots` (which must already be realpath'd).
166
- */
167
- export function collectImports(content: string, fromDir: string, home: string, allowedRoots: string[], seen: Set<string>, budget: ImportBudget = createImportBudget(), depth = 0, importer?: string, isExcluded?: (realPath: string) => boolean): ImportedFile[] {
162
+ /** Optional controls for a collection run: the byte/file budget shared across the
163
+ * whole run, the path of the file this content came from (seeds each top-level
164
+ * import's `parent`), and the exclusion predicate. Recursion depth is internal. */
165
+ export interface CollectImportsOptions {
166
+ budget?: ImportBudget
167
+ importer?: string
168
+ isExcluded?: (realPath: string) => boolean
169
+ }
170
+
171
+ /** The parts of a collection run that stay fixed across the recursion: resolution
172
+ * roots, the seen/budget accumulators, and the exclusion predicate. Only content,
173
+ * fromDir, depth and the parent path change from one level to the next. */
174
+ interface ImportScan {
175
+ home: string
176
+ allowedRoots: string[]
177
+ seen: Set<string>
178
+ budget: ImportBudget
179
+ isExcluded?: (realPath: string) => boolean
180
+ }
181
+
182
+ /** One recursion level: read the imports named in `content`, then recurse into each. */
183
+ function collectFrom(scan: ImportScan, content: string, fromDir: string, depth: number, importer?: string): ImportedFile[] {
168
184
  if (depth >= MAX_IMPORT_DEPTH) return []
169
185
  const out: ImportedFile[] = []
170
186
  for (const target of importTargets(content)) {
171
187
  // Checked before the read so an exhausted budget costs no I/O.
172
- if (budget.files === 0 || budget.bytes === 0) {
173
- budget.dropped += 1
188
+ if (scan.budget.files === 0 || scan.budget.bytes === 0) {
189
+ scan.budget.dropped += 1
174
190
  continue
175
191
  }
176
- const file = readImport(target, fromDir, home, allowedRoots, seen, isExcluded)
192
+ const file = readImport(target, fromDir, scan.home, scan.allowedRoots, scan.seen, scan.isExcluded)
177
193
  if (!file) continue
178
- budget.files -= 1
179
- const kept = file.body.slice(0, budget.bytes)
180
- budget.bytes -= kept.length
194
+ scan.budget.files -= 1
195
+ const kept = file.body.slice(0, scan.budget.bytes)
196
+ scan.budget.bytes -= kept.length
181
197
  const body = kept.length < file.body.length ? `${kept.trim()}\n${IMPORT_TRUNCATED_MARKER}` : kept.trim()
182
198
  // Comments are stripped before the scan for further imports, so a
183
199
  // commented-out @import stays dead at every depth, matching the top level
184
200
  // (whose bodies arrive here already stripped by the caller).
185
- out.push({ path: file.real, body, parent: importer }, ...collectImports(stripBlockComments(kept), path.dirname(file.real), home, allowedRoots, seen, budget, depth + 1, file.real, isExcluded))
201
+ out.push({ path: file.real, body, parent: importer }, ...collectFrom(scan, stripBlockComments(kept), path.dirname(file.real), depth + 1, file.real))
186
202
  }
187
203
  return out
188
204
  }
189
205
 
206
+ /**
207
+ * Collect the contents of every file transitively imported via `@path`, in
208
+ * discovery order. Imports are resolved through symlinks and kept within
209
+ * `allowedRoots` (which must already be realpath'd).
210
+ */
211
+ export function collectImports(content: string, fromDir: string, home: string, allowedRoots: string[], seen: Set<string>, options: CollectImportsOptions = {}): ImportedFile[] {
212
+ const scan: ImportScan = { home, allowedRoots, seen, budget: options.budget ?? createImportBudget(), isExcluded: options.isExcluded }
213
+ return collectFrom(scan, content, fromDir, 0, options.importer)
214
+ }
215
+
190
216
  /**
191
217
  * Roots an importing file may pull from.
192
218
  *
@@ -357,6 +383,114 @@ export function isExcludedPath(absPath: string, globs: string[], home: string):
357
383
  })
358
384
  }
359
385
 
386
+ /** Apply claudeMdExcludes and comment-stripping to pi's native context blocks,
387
+ * rewriting the assembled prompt by exact substring: an excluded file's block is
388
+ * removed, a surviving file's block is replaced with its comment-stripped body. A
389
+ * wrapper not found in the prompt is skipped rather than risk corrupting it.
390
+ * Returns the rewritten prompt and the files that survived exclusion (stripped). */
391
+ function rewriteNativeBlocks(prompt: string, native: Array<{ path: string; content: string }>, excluded: (absPath: string) => boolean): { prompt: string; changed: boolean; kept: Array<{ path: string; content: string }> } {
392
+ let changed = false
393
+ const kept: Array<{ path: string; content: string }> = []
394
+ for (const file of native) {
395
+ const wrapper = instructionsBlock(file.path, file.content)
396
+ if (excluded(file.path)) {
397
+ const removed = removeBlock(prompt, wrapper)
398
+ if (removed !== null) {
399
+ prompt = removed
400
+ changed = true
401
+ }
402
+ continue
403
+ }
404
+ const stripped = stripBlockComments(file.content)
405
+ if (stripped !== file.content) {
406
+ const replaced = replaceBlock(prompt, wrapper, instructionsBlock(file.path, stripped))
407
+ if (replaced !== null) {
408
+ prompt = replaced
409
+ changed = true
410
+ }
411
+ }
412
+ kept.push({ path: file.path, content: stripped })
413
+ }
414
+ return { prompt, changed, kept }
415
+ }
416
+
417
+ /** Claude's --add-dir memory files, minus any pi already loaded natively (added to
418
+ * `seenSet` here so an @import cannot pull one in twice) and any the excludes drop
419
+ * or that strip to nothing. Each survivor is comment-stripped and tagged with its
420
+ * additional dir, so its relative imports can resolve from there. */
421
+ function additionalDirExtras(addDirs: string[], seenSet: Set<string>, excluded: (absPath: string) => boolean, includeLocal: boolean): Array<{ path: string; content: string; dir: string }> {
422
+ const extras: Array<{ path: string; content: string; dir: string }> = []
423
+ for (const dir of addDirs) {
424
+ for (const file of additionalDirContextFiles(dir, includeLocal)) {
425
+ const [real] = realRoots([file.path])
426
+ const key = real ?? file.path
427
+ if (seenSet.has(key)) continue // pi already loaded it natively
428
+ seenSet.add(key)
429
+ if (excluded(file.path)) continue
430
+ const stripped = stripBlockComments(file.content)
431
+ if (stripped.trim().length === 0) continue
432
+ extras.push({ path: file.path, content: stripped, dir })
433
+ }
434
+ }
435
+ return extras
436
+ }
437
+
438
+ /** Resolve every context and additional-dir file's @imports through the one shared
439
+ * budget, each with roots scoped to the importing file so a project file never
440
+ * reaches user config. */
441
+ function expandImports(contextFiles: Array<{ path: string; content: string }>, extras: Array<{ path: string; content: string; dir: string }>, home: string, cwd: string, seenSet: Set<string>, excluded: (absPath: string) => boolean, budget: ImportBudget): ImportedFile[] {
442
+ const imported: ImportedFile[] = []
443
+ for (const file of contextFiles) {
444
+ const allowedRoots = rootsForImporter(file.path, home, cwd)
445
+ imported.push(...collectImports(file.content, path.dirname(file.path), home, allowedRoots, seenSet, { budget, importer: file.path, isExcluded: excluded }))
446
+ }
447
+ for (const extra of extras) {
448
+ // The additional dir itself is an allowed root, so its files' relative imports
449
+ // resolve even from .claude/rules two levels down.
450
+ const allowedRoots = [...realRoots([extra.dir]), ...rootsForImporter(extra.path, home, cwd)]
451
+ imported.push(...collectImports(extra.content, path.dirname(extra.path), home, allowedRoots, seenSet, { budget, importer: extra.path, isExcluded: excluded }))
452
+ }
453
+ return imported
454
+ }
455
+
456
+ /** The CLAUDE.local.md bodies appended after the native context, announced as they
457
+ * are added (only the non-empty ones, matching what actually reaches the prompt). */
458
+ function localContextAddition(keptLocals: Array<{ path: string; content: string }>, announce: (event: InstructionLoadEvent) => void): string {
459
+ let addition = ''
460
+ for (const local of keptLocals) {
461
+ if (local.content.trim().length > 0) {
462
+ addition += `\n\n## CLAUDE.local.md (${local.path})\n\n${local.content.trim()}`
463
+ announce({ file_path: local.path, memory_type: 'Local', load_reason: 'session_start' })
464
+ }
465
+ }
466
+ return addition
467
+ }
468
+
469
+ /** The --add-dir memory files appended as extra project_instructions blocks.
470
+ * Additional dirs are extra working directories, so their files are Project-typed
471
+ * regardless of where the dir sits (Local for a CLAUDE.local.md). */
472
+ function additionalDirsAddition(extras: Array<{ path: string; content: string; dir: string }>, announce: (event: InstructionLoadEvent) => void): string {
473
+ let addition = ''
474
+ for (const extra of extras) {
475
+ addition += `\n\n${instructionsBlock(extra.path, extra.content)}`
476
+ announce({ file_path: extra.path, memory_type: path.basename(extra.path) === 'CLAUDE.local.md' ? 'Local' : 'Project', load_reason: 'session_start' })
477
+ }
478
+ return addition
479
+ }
480
+
481
+ /** The `## Imported context (@)` section for every resolved @import, with the
482
+ * budget-exhaustion notice, announcing each as an `include`. Empty when nothing
483
+ * was imported. */
484
+ function importedAddition(imported: ImportedFile[], budget: ImportBudget, home: string, projectRoot: string, announce: (event: InstructionLoadEvent) => void): string {
485
+ if (imported.length === 0) return ''
486
+ const section = imported.map((entry) => `### ${entry.path}\n\n${stripBlockComments(entry.body)}`).join('\n\n')
487
+ const notice = budget.dropped === 0 ? '' : `\n\n${budget.dropped} further @imports were skipped: the import budget (${MAX_IMPORT_FILES} files, ${MAX_IMPORT_BYTES} bytes) is spent.`
488
+ for (const entry of imported) {
489
+ announce({ file_path: entry.path, memory_type: memoryTypeForPath(entry.path, home, projectRoot), load_reason: 'include', ...(entry.parent === undefined ? {} : { parent_file_path: entry.parent }) })
490
+ }
491
+ return `\n\n## Imported context (@)\n\n${section}${notice}`
492
+ }
493
+
360
494
  export default function contextImportsExtension(pi: ExtensionAPI) {
361
495
  let localContexts: Array<{ path: string; content: string }> = []
362
496
  // Whether project settings may contribute claudeMdExcludes; decided at session
@@ -416,37 +550,16 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
416
550
  const excluded = (absPath: string): boolean => isExcludedPath(absPath, excludeGlobs, home)
417
551
  const projectRoot = repoRoot(cwd) ?? cwd
418
552
 
419
- let prompt = event.systemPrompt
420
- let changed = false
421
-
422
553
  // claudeMdExcludes drops an excluded file's block from the assembled prompt and
423
554
  // from import expansion; surviving blocks get block-level comments stripped.
424
- // Both rewrite by exact substring: a wrapper that is not found in the prompt is
425
- // skipped rather than risk corrupting it.
426
- const keptNative: Array<{ path: string; content: string }> = []
427
- for (const file of native) {
428
- const wrapper = instructionsBlock(file.path, file.content)
429
- if (excluded(file.path)) {
430
- const removed = removeBlock(prompt, wrapper)
431
- if (removed !== null) {
432
- prompt = removed
433
- changed = true
434
- }
435
- continue
436
- }
437
- const stripped = stripBlockComments(file.content)
438
- if (stripped !== file.content) {
439
- const replaced = replaceBlock(prompt, wrapper, instructionsBlock(file.path, stripped))
440
- if (replaced !== null) {
441
- prompt = replaced
442
- changed = true
443
- }
444
- }
445
- keptNative.push({ path: file.path, content: stripped })
446
- // Exclusion is owned here, so the session_start InstructionsLoaded events
447
- // for pi's native context files are published here too, only for files
448
- // that actually survived it: Claude fires no event for a file it never
449
- // loaded. The hooks extension consumes them off the shared bus.
555
+ const rewrite = rewriteNativeBlocks(event.systemPrompt, native, excluded)
556
+ let prompt = rewrite.prompt
557
+ let changed = rewrite.changed
558
+ // Exclusion is owned here, so the session_start InstructionsLoaded events for
559
+ // pi's native context files are published here too, only for files that
560
+ // survived it: Claude fires no event for a file it never loaded. The hooks
561
+ // extension consumes them off the shared bus.
562
+ for (const file of rewrite.kept) {
450
563
  announce({ file_path: file.path, memory_type: memoryTypeForPath(file.path, home, projectRoot), load_reason: 'session_start' })
451
564
  }
452
565
 
@@ -460,69 +573,27 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
460
573
  }
461
574
 
462
575
  const keptLocals = localContexts.filter((local) => !excluded(local.path)).map((local) => ({ path: local.path, content: stripBlockComments(local.content) }))
463
- const contextFiles = [...keptNative, ...keptLocals]
576
+ const contextFiles = [...rewrite.kept, ...keptLocals]
464
577
 
465
578
  // Seed with every loaded context file path, excluded ones included, so pi's own
466
579
  // files are never re-imported and an excluded file cannot return as an import.
467
- const seen = realRoots([...native, ...localContexts].map((file) => file.path))
468
- const seenSet = new Set(seen)
580
+ const seenSet = new Set(realRoots([...native, ...localContexts].map((file) => file.path)))
469
581
 
470
582
  // Claude's --add-dir memory loading, env-gated. The files join the seen set
471
583
  // before import expansion so an @import cannot pull one in twice, and they get
472
584
  // the same exclude and comment-strip treatment as native context files.
473
585
  const addDirs = additionalDirsClaudeMdEnabled() ? parseAdditionalDirs(pi.getFlag?.('add-dir'), home, cwd) : []
474
- const extras: Array<{ path: string; content: string; dir: string }> = []
475
- for (const dir of addDirs) {
476
- for (const file of additionalDirContextFiles(dir, projectApproved)) {
477
- const [real] = realRoots([file.path])
478
- const key = real ?? file.path
479
- if (seenSet.has(key)) continue // pi already loaded it natively
480
- seenSet.add(key)
481
- if (excluded(file.path)) continue
482
- const stripped = stripBlockComments(file.content)
483
- if (stripped.trim().length === 0) continue
484
- extras.push({ path: file.path, content: stripped, dir })
485
- }
486
- }
586
+ const extras = additionalDirExtras(addDirs, seenSet, excluded, projectApproved)
487
587
 
488
- const imported: ImportedFile[] = []
489
588
  // One budget for the whole run, so N context files cannot each spend a full one.
490
589
  // Exclusion applies inside the recursion: an excluded @import is skipped before
491
590
  // it is read, so its transitive imports never load and it spends no budget.
492
591
  const budget = createImportBudget()
493
- for (const file of contextFiles) {
494
- // Roots are scoped per importing file: a project file never reaches user config.
495
- const allowedRoots = rootsForImporter(file.path, home, cwd)
496
- imported.push(...collectImports(file.content, path.dirname(file.path), home, allowedRoots, seenSet, budget, 0, file.path, excluded))
497
- }
498
- for (const extra of extras) {
499
- // The additional dir itself is an allowed root, so its files' relative
500
- // imports resolve even from .claude/rules two levels down.
501
- const allowedRoots = [...realRoots([extra.dir]), ...rootsForImporter(extra.path, home, cwd)]
502
- imported.push(...collectImports(extra.content, path.dirname(extra.path), home, allowedRoots, seenSet, budget, 0, extra.path, excluded))
503
- }
592
+ const imported = expandImports(contextFiles, extras, home, cwd, seenSet, excluded, budget)
504
593
 
505
- let addition = ''
506
- for (const local of keptLocals) {
507
- if (local.content.trim().length > 0) {
508
- addition += `\n\n## CLAUDE.local.md (${local.path})\n\n${local.content.trim()}`
509
- announce({ file_path: local.path, memory_type: 'Local', load_reason: 'session_start' })
510
- }
511
- }
512
- for (const extra of extras) {
513
- addition += `\n\n${instructionsBlock(extra.path, extra.content)}`
514
- // Additional dirs are extra working directories, so their memory files are
515
- // Project-typed regardless of where the dir sits (Local for CLAUDE.local.md).
516
- announce({ file_path: extra.path, memory_type: path.basename(extra.path) === 'CLAUDE.local.md' ? 'Local' : 'Project', load_reason: 'session_start' })
517
- }
518
- if (imported.length > 0) {
519
- const section = imported.map((entry) => `### ${entry.path}\n\n${stripBlockComments(entry.body)}`).join('\n\n')
520
- const notice = budget.dropped === 0 ? '' : `\n\n${budget.dropped} further @imports were skipped: the import budget (${MAX_IMPORT_FILES} files, ${MAX_IMPORT_BYTES} bytes) is spent.`
521
- addition += `\n\n## Imported context (@)\n\n${section}${notice}`
522
- for (const entry of imported) {
523
- announce({ file_path: entry.path, memory_type: memoryTypeForPath(entry.path, home, projectRoot), load_reason: 'include', ...(entry.parent === undefined ? {} : { parent_file_path: entry.parent }) })
524
- }
525
- }
594
+ let addition = localContextAddition(keptLocals, announce)
595
+ addition += additionalDirsAddition(extras, announce)
596
+ addition += importedAddition(imported, budget, home, projectRoot, announce)
526
597
  if (!changed && addition.length === 0) return
527
598
 
528
599
  return { systemPrompt: prompt + addition }
@@ -13,12 +13,15 @@
13
13
  * the checkpoint (files created after the checkpoint are left in place).
14
14
  */
15
15
 
16
+ import { createHash } from 'node:crypto'
16
17
  import * as fs from 'node:fs'
17
18
  import * as os from 'node:os'
18
19
  import * as path from 'node:path'
19
20
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent'
20
21
 
21
22
  const CUSTOM_TYPE = 'git-checkpoint'
23
+ /** Sidecar inside the bare shadow repo recording the work tree it snapshots. */
24
+ const WORK_TREE_FILE = 'pi-work-tree'
22
25
  const PROMPT_SNIPPET_LENGTH = 60
23
26
  const RESTORE_MODES = ['Code and conversation', 'Conversation only', 'Code only']
24
27
 
@@ -72,6 +75,31 @@ export function sessionSlug(sessionFile: string | undefined): string {
72
75
  return path.basename(sessionFile).replace(/[^\w.-]+/g, '_')
73
76
  }
74
77
 
78
+ /** A stable per-directory key, so a session resumed elsewhere gets its own shadow. */
79
+ function cwdSlug(cwd: string): string {
80
+ const resolved = path.resolve(cwd)
81
+ const hash = createHash('sha256').update(resolved).digest('hex').slice(0, 8)
82
+ return `${path.basename(resolved).replace(/[^\w.-]+/g, '_')}-${hash}`
83
+ }
84
+
85
+ /** The work tree a shadow repo was created against, or undefined for a repo that
86
+ * predates the sidecar or does not exist yet. */
87
+ function recordedWorkTree(shadowDir: string): string | undefined {
88
+ try {
89
+ return fs.readFileSync(path.join(shadowDir, WORK_TREE_FILE), 'utf8').trim() || undefined
90
+ } catch {
91
+ return undefined
92
+ }
93
+ }
94
+
95
+ function rememberWorkTree(shadowDir: string, cwd: string): void {
96
+ try {
97
+ fs.writeFileSync(path.join(shadowDir, WORK_TREE_FILE), `${cwd}\n`)
98
+ } catch {
99
+ // best effort: without the marker the next resume simply cannot detect a move
100
+ }
101
+ }
102
+
75
103
  function extractText(content: unknown): string {
76
104
  if (typeof content === 'string') return content
77
105
  if (!Array.isArray(content)) return ''
@@ -134,6 +162,16 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
134
162
  const sessionFile = (ctx.sessionManager as { getSessionFile?: () => string | undefined }).getSessionFile?.()
135
163
  const checkpointsRoot = path.join(os.homedir(), '.pi', 'agent', 'checkpoints')
136
164
  shadowDir = path.join(checkpointsRoot, sessionSlug(sessionFile))
165
+ // A resumed session can arrive from a different directory than the one the shadow
166
+ // snapshotted; restoring those commits here would silently overwrite unrelated
167
+ // same-named files. Key a fresh shadow to this directory instead of ever checking
168
+ // one tree out into another. Resuming back in the recorded directory takes the
169
+ // original shadow again, so its checkpoints stay restorable there.
170
+ const recorded = recordedWorkTree(shadowDir)
171
+ if (recorded && path.resolve(recorded) !== path.resolve(ctx.cwd)) {
172
+ shadowDir = path.join(checkpointsRoot, `${sessionSlug(sessionFile)}-${cwdSlug(ctx.cwd)}`)
173
+ ctx.ui.notify(`Checkpoints for this session were recorded in ${recorded}; starting fresh checkpoints for ${ctx.cwd} (earlier ones are not restorable here)`, 'warning')
174
+ }
137
175
  pruneCheckpointRepos(checkpointsRoot, CHECKPOINT_RETENTION_DAYS, shadowDir)
138
176
  const check = await pi.exec('git', ['--git-dir', shadowDir, 'rev-parse', '--git-dir'], { cwd: ctx.cwd })
139
177
  if (check.code !== 0) {
@@ -147,6 +185,8 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
147
185
  await pi.exec('git', ['--git-dir', shadowDir, 'config', 'user.email', 'checkpoint@pi-code'], { cwd: ctx.cwd })
148
186
  await pi.exec('git', ['--git-dir', shadowDir, 'config', 'user.name', 'pi-code-checkpoint'], { cwd: ctx.cwd })
149
187
  }
188
+ // Written on every start, so repos that predate the sidecar pick it up too.
189
+ rememberWorkTree(shadowDir, ctx.cwd)
150
190
  }
151
191
 
152
192
  /** `checkout -f <ref> -- .` errors when the ref's tree holds no files, so an empty