pi-code 1.0.8 → 1.0.10
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/extensions/claude-rules.ts +33 -29
- package/extensions/commands.ts +35 -4
- package/extensions/context-imports.ts +284 -51
- package/extensions/context-usage.ts +45 -0
- package/extensions/env-settings.ts +130 -0
- package/extensions/git-checkpoint.ts +21 -3
- package/extensions/hooks.ts +108 -18
- package/extensions/internal/command-file.ts +27 -2
- package/extensions/internal/config-dir.ts +24 -0
- package/extensions/internal/path-rules.ts +45 -0
- package/extensions/internal/plugins.ts +60 -3
- package/extensions/mcp.ts +186 -41
- package/extensions/memory.ts +118 -4
- package/extensions/notify.ts +3 -1
- package/extensions/output-styles.ts +3 -2
- package/extensions/skills.ts +2 -1
- package/extensions/status-line.ts +48 -14
- package/extensions/subagent/agents.ts +2 -1
- package/extensions/subagent/index.ts +19 -6
- package/extensions/thinking.ts +80 -0
- package/package.json +1 -1
|
@@ -10,14 +10,23 @@
|
|
|
10
10
|
* imported content plus the approval-gated CLAUDE.local.md body. The base
|
|
11
11
|
* files pi already injected are never re-appended.
|
|
12
12
|
*
|
|
13
|
+
* It also loads the Claude Code memory locations pi's own loader misses, each
|
|
14
|
+
* through the same exclude/strip/announce/import pipeline: the user-scope
|
|
15
|
+
* ~/.claude/CLAUDE.md (the user's own file, no approval gate, @imports at
|
|
16
|
+
* user-config roots), the project-scope alternate ./.claude/CLAUDE.md (nearest at
|
|
17
|
+
* or above cwd, approval-gated, deduped against pi's native blocks, @imports at
|
|
18
|
+
* project roots), and the enterprise managed CLAUDE.md file deployed beside
|
|
19
|
+
* managed-settings.json.
|
|
20
|
+
*
|
|
13
21
|
* It also rewrites the context blocks pi assembled, by exact-substring
|
|
14
22
|
* replacement of the wrapper reconstructed from each file's path+content (a
|
|
15
|
-
* wrapper that is not found is skipped, never guessed at):
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
23
|
+
* wrapper that is not found is skipped, never guessed at): the managed claudeMd
|
|
24
|
+
* (the managed CLAUDE.md file first, then the managed-settings `claudeMd` key)
|
|
25
|
+
* and the user CLAUDE.md are prepended at the top of <project_context> in Claude's
|
|
26
|
+
* order (managed, user, then pi's native project blocks; managed is managed-source
|
|
27
|
+
* only and never excludable), files matching the merged `claudeMdExcludes` globs
|
|
28
|
+
* are removed along with their imports, and block-level HTML comments are stripped
|
|
29
|
+
* from every surviving body (see internal/strip-comments).
|
|
21
30
|
*
|
|
22
31
|
* Security: context files can come from an untrusted project, so imports are
|
|
23
32
|
* confined (after resolving symlinks) to the working directory plus its
|
|
@@ -43,22 +52,26 @@
|
|
|
43
52
|
*
|
|
44
53
|
* Loads are also announced on the shared instruction-events bus for the
|
|
45
54
|
* InstructionsLoaded hook: `include` for each resolved @import, `session_start`
|
|
46
|
-
* for the native context files that survived claudeMdExcludes plus
|
|
47
|
-
* CLAUDE.
|
|
48
|
-
*
|
|
49
|
-
* exclusion
|
|
50
|
-
*
|
|
55
|
+
* for the native context files that survived claudeMdExcludes plus CLAUDE.local.md,
|
|
56
|
+
* the user (User) and project ./.claude/CLAUDE.md (Project), the managed file
|
|
57
|
+
* (Managed) and additional-dir files, once per file per session. This extension
|
|
58
|
+
* owns exclusion, so it owns the announcements too: a file the exclusion removed
|
|
59
|
+
* never announces, and the hooks extension only consumes the bus (emit is
|
|
60
|
+
* synchronous, so extension order does not matter). The managed-settings `claudeMd`
|
|
61
|
+
* key is not a file pi loaded, so like today it is inserted but not announced.
|
|
51
62
|
*
|
|
52
63
|
* Docs: https://code.claude.com/docs/en/memory.md (imports)
|
|
53
64
|
*/
|
|
54
65
|
|
|
66
|
+
import { createHash } from 'node:crypto'
|
|
55
67
|
import * as fs from 'node:fs'
|
|
56
68
|
import * as os from 'node:os'
|
|
57
69
|
import * as path from 'node:path'
|
|
58
70
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
59
71
|
|
|
72
|
+
import { claudeConfigDir } from './internal/config-dir.js'
|
|
60
73
|
import { type InstructionLoadEvent, memoryTypeForPath, publishInstructionLoad } from './internal/instruction-events.js'
|
|
61
|
-
import { readManagedSettings } from './internal/managed-settings.js'
|
|
74
|
+
import { managedSettingsPath, readManagedSettings } from './internal/managed-settings.js'
|
|
62
75
|
import { globToRegExpSource } from './internal/path-rules.js'
|
|
63
76
|
import { isProjectApproved, isProjectApprovedSilently } from './internal/project-approval.js'
|
|
64
77
|
import { ancestorFiles, findNearestFile, repoRoot } from './internal/project-root.js'
|
|
@@ -222,7 +235,7 @@ export function collectImports(content: string, fromDir: string, home: string, a
|
|
|
222
235
|
* would let it read them into the system prompt.
|
|
223
236
|
*/
|
|
224
237
|
export function rootsForImporter(importer: string, home: string, cwd: string): string[] {
|
|
225
|
-
const userRoots = realRoots([
|
|
238
|
+
const userRoots = realRoots([claudeConfigDir(home), path.join(home, '.pi')])
|
|
226
239
|
const [real] = realRoots([importer])
|
|
227
240
|
const fromUserConfig = real !== undefined && isUnder(real, userRoots)
|
|
228
241
|
if (fromUserConfig) return realRoots([cwd, ...userRoots])
|
|
@@ -286,9 +299,34 @@ export function additionalDirContextFiles(dir: string, includeLocal: boolean): A
|
|
|
286
299
|
return files
|
|
287
300
|
}
|
|
288
301
|
|
|
289
|
-
/** Path label given to the managed claudeMd block; not a file pi loaded. */
|
|
302
|
+
/** Path label given to the managed claudeMd settings-key block; not a file pi loaded. */
|
|
290
303
|
export const MANAGED_CLAUDE_MD_PATH = 'managed-settings.json (claudeMd)'
|
|
291
304
|
|
|
305
|
+
let managedClaudeMdPathOverride: string | undefined
|
|
306
|
+
|
|
307
|
+
/** Test seam mirroring setManagedSettingsPath: point the managed CLAUDE.md file
|
|
308
|
+
* readers consult at a writable directory. */
|
|
309
|
+
export function setManagedClaudeMdPath(file?: string): void {
|
|
310
|
+
managedClaudeMdPathOverride = file
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** The managed CLAUDE.md file path: alongside managed-settings.json, in the same OS
|
|
314
|
+
* directory IT deploys the enterprise policy to (managed-settings.ts owns that
|
|
315
|
+
* directory per platform). Organizations ship a CLAUDE.md there to load before user
|
|
316
|
+
* and project context. Overridable for tests. */
|
|
317
|
+
export function managedClaudeMdPath(): string {
|
|
318
|
+
return managedClaudeMdPathOverride ?? path.join(path.dirname(managedSettingsPath()), 'CLAUDE.md')
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** The managed CLAUDE.md file body, or '' when absent or unreadable. */
|
|
322
|
+
export function readManagedClaudeMdFile(): string {
|
|
323
|
+
try {
|
|
324
|
+
return fs.readFileSync(managedClaudeMdPath(), 'utf-8')
|
|
325
|
+
} catch {
|
|
326
|
+
return ''
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
292
330
|
/** pi's exact per-file wrapper inside <project_context>, reconstructed from
|
|
293
331
|
* path+content for exact-substring rewriting. tests/context-imports.test.ts pins
|
|
294
332
|
* this format against pi's own source so drift fails loudly instead of silently
|
|
@@ -317,10 +355,12 @@ function replaceBlock(prompt: string, wrapper: string, replacement: string): str
|
|
|
317
355
|
return prompt.slice(0, at) + replacement + prompt.slice(at + wrapper.length)
|
|
318
356
|
}
|
|
319
357
|
|
|
320
|
-
/** Insert
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
|
|
358
|
+
/** Insert a block at the top of <project_context>, before the files pi loaded;
|
|
359
|
+
* when pi assembled no context block, add one in pi's shape. Used for the blocks
|
|
360
|
+
* Claude loads ahead of pi's native project context: managed claudeMd (file then
|
|
361
|
+
* key) and the user CLAUDE.md. Each call prepends, so the last block inserted ends
|
|
362
|
+
* up highest, which is how the managed/user/native order is built (see caller). */
|
|
363
|
+
function withTopBlock(prompt: string, block: string): string {
|
|
324
364
|
for (const anchor of [CONTEXT_OPENER, '<project_context>\n\n']) {
|
|
325
365
|
const at = prompt.indexOf(anchor)
|
|
326
366
|
if (at === -1) continue
|
|
@@ -335,7 +375,7 @@ function withManagedBlock(prompt: string, block: string): string {
|
|
|
335
375
|
* settings.local.json (nearest at or above cwd) only when the project is
|
|
336
376
|
* approved. Managed settings are read separately by the caller. */
|
|
337
377
|
export function claudeMdExcludeFiles(cwd: string, home: string, approved: boolean): string[] {
|
|
338
|
-
const files = [path.join(home, '
|
|
378
|
+
const files = [path.join(claudeConfigDir(home), 'settings.json')]
|
|
339
379
|
if (!approved) return files
|
|
340
380
|
for (const name of ['settings.json', 'settings.local.json']) {
|
|
341
381
|
files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
|
|
@@ -453,6 +493,16 @@ function expandImports(contextFiles: Array<{ path: string; content: string }>, e
|
|
|
453
493
|
return imported
|
|
454
494
|
}
|
|
455
495
|
|
|
496
|
+
/** The project-scope ./.claude/CLAUDE.md appended as a project_instructions block,
|
|
497
|
+
* announced as it is added (only when non-empty, matching what reaches the prompt).
|
|
498
|
+
* Claude reads project instructions from ./CLAUDE.md OR ./.claude/CLAUDE.md; pi loads
|
|
499
|
+
* the former natively, so this fills the alternate location as an extra project block. */
|
|
500
|
+
function projectContextAddition(kept: { path: string; content: string } | undefined, home: string, projectRoot: string, announce: (event: InstructionLoadEvent) => void): string {
|
|
501
|
+
if (kept === undefined || kept.content.trim().length === 0) return ''
|
|
502
|
+
announce({ file_path: kept.path, memory_type: memoryTypeForPath(kept.path, home, projectRoot), load_reason: 'session_start' })
|
|
503
|
+
return `\n\n${instructionsBlock(kept.path, kept.content.trim())}`
|
|
504
|
+
}
|
|
505
|
+
|
|
456
506
|
/** The CLAUDE.local.md bodies appended after the native context, announced as they
|
|
457
507
|
* are added (only the non-empty ones, matching what actually reaches the prompt). */
|
|
458
508
|
function localContextAddition(keptLocals: Array<{ path: string; content: string }>, announce: (event: InstructionLoadEvent) => void): string {
|
|
@@ -491,8 +541,74 @@ function importedAddition(imported: ImportedFile[], budget: ImportBudget, home:
|
|
|
491
541
|
return `\n\n## Imported context (@)\n\n${section}${notice}`
|
|
492
542
|
}
|
|
493
543
|
|
|
544
|
+
/** Prepend the managed and user memory blocks Claude loads ahead of pi's native project
|
|
545
|
+
* context, top to bottom: managed file, managed key, then the user CLAUDE.md. withTopBlock
|
|
546
|
+
* prepends, so they are inserted bottom-up (user, then managed key, then managed file just
|
|
547
|
+
* below) to land in that order. keptUser was already exclude-checked and comment-stripped by
|
|
548
|
+
* the caller like every other file; the managed claudeMd is never excludable and comes from
|
|
549
|
+
* two managed-only surfaces, the settings key (ignored in user/project settings) and the file
|
|
550
|
+
* IT deploys beside managed-settings.json, re-read every turn so a policy change applies
|
|
551
|
+
* immediately (only its announce is deduped per session). Returns the grown prompt, whether
|
|
552
|
+
* anything was added, and the managed file body (reused for the import memo key). */
|
|
553
|
+
function prependMemoryBlocks(prompt: string, changed: boolean, keptUser: { path: string; content: string } | undefined, managed: Record<string, unknown>, announce: (event: InstructionLoadEvent) => void): { prompt: string; changed: boolean; managedFile: string } {
|
|
554
|
+
if (keptUser !== undefined && keptUser.content.trim().length > 0) {
|
|
555
|
+
prompt = withTopBlock(prompt, instructionsBlock(keptUser.path, keptUser.content.trim()))
|
|
556
|
+
changed = true
|
|
557
|
+
announce({ file_path: keptUser.path, memory_type: 'User', load_reason: 'session_start' })
|
|
558
|
+
}
|
|
559
|
+
const managedKey = typeof managed.claudeMd === 'string' ? stripBlockComments(managed.claudeMd).trim() : ''
|
|
560
|
+
if (managedKey.length > 0) {
|
|
561
|
+
prompt = withTopBlock(prompt, instructionsBlock(MANAGED_CLAUDE_MD_PATH, managedKey))
|
|
562
|
+
changed = true
|
|
563
|
+
}
|
|
564
|
+
const managedFile = stripBlockComments(readManagedClaudeMdFile()).trim()
|
|
565
|
+
if (managedFile.length > 0) {
|
|
566
|
+
prompt = withTopBlock(prompt, instructionsBlock(managedClaudeMdPath(), managedFile))
|
|
567
|
+
changed = true
|
|
568
|
+
announce({ file_path: managedClaudeMdPath(), memory_type: 'Managed', load_reason: 'session_start' })
|
|
569
|
+
}
|
|
570
|
+
return { prompt, changed, managedFile }
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** Everything the import expansion depends on, hashed to a memo key: a turn whose inputs
|
|
574
|
+
* match a prior key and whose recorded mtimes are unchanged reuses the previous expansion
|
|
575
|
+
* outright. The native/local paths, the user/project-.claude additions, and the managed file
|
|
576
|
+
* body seed the "seen" set, so a change in which of them exist (even an excluded one that
|
|
577
|
+
* never reaches contextFiles) changes the key; the managed file is re-read every turn, so a
|
|
578
|
+
* change in its content re-expands and keeps the seed self-consistent. */
|
|
579
|
+
function buildImportMemoKey(input: {
|
|
580
|
+
cwd: string
|
|
581
|
+
home: string
|
|
582
|
+
projectApproved: boolean
|
|
583
|
+
addDirsRaw: string
|
|
584
|
+
excludeGlobs: string[]
|
|
585
|
+
native: Array<{ path: string; content: string }>
|
|
586
|
+
localContexts: Array<{ path: string; content: string }>
|
|
587
|
+
userContext: { path: string; content: string } | undefined
|
|
588
|
+
projectDotClaude: { path: string; content: string } | undefined
|
|
589
|
+
managedFile: string
|
|
590
|
+
contextFiles: Array<{ path: string; content: string }>
|
|
591
|
+
}): string {
|
|
592
|
+
const keyHash = createHash('sha256')
|
|
593
|
+
keyHash.update(`${input.cwd}\0${input.home}\0${input.projectApproved}\0${input.addDirsRaw}\0${input.excludeGlobs.join(',')}\0`)
|
|
594
|
+
for (const file of [...input.native, ...input.localContexts]) keyHash.update(`${file.path}\0`)
|
|
595
|
+
if (input.userContext !== undefined) keyHash.update(`${input.userContext.path}\0`)
|
|
596
|
+
if (input.projectDotClaude !== undefined) keyHash.update(`${input.projectDotClaude.path}\0`)
|
|
597
|
+
keyHash.update(`${input.managedFile}\0`)
|
|
598
|
+
for (const file of input.contextFiles) keyHash.update(`${file.path}\0${file.content}\0`)
|
|
599
|
+
return keyHash.digest('hex')
|
|
600
|
+
}
|
|
601
|
+
|
|
494
602
|
export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
495
603
|
let localContexts: Array<{ path: string; content: string }> = []
|
|
604
|
+
// ~/.claude/CLAUDE.md, Claude's user-scope memory (all projects). The user's own
|
|
605
|
+
// file, so it needs no project approval; read once at session start like the
|
|
606
|
+
// locals, so a mid-session body edit applies next session, matching Claude.
|
|
607
|
+
let userContext: { path: string; content: string } | undefined
|
|
608
|
+
// The project-scope alternate location ./.claude/CLAUDE.md. pi loads ./CLAUDE.md
|
|
609
|
+
// natively but not this one; repo-controlled, so it is approval-gated like the
|
|
610
|
+
// locals and read once at session start.
|
|
611
|
+
let projectDotClaude: { path: string; content: string } | undefined
|
|
496
612
|
// Whether project settings may contribute claudeMdExcludes; decided at session
|
|
497
613
|
// start with the silent check, so no prompt fires mid-flight.
|
|
498
614
|
let projectApproved = false
|
|
@@ -507,6 +623,87 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
507
623
|
publishInstructionLoad(pi.events, event)
|
|
508
624
|
}
|
|
509
625
|
|
|
626
|
+
// before_agent_start fires every turn, but its inputs almost never change
|
|
627
|
+
// mid-session. The settings-derived environment (managed settings, exclude
|
|
628
|
+
// globs, repo root) is cached per cwd, and the whole import expansion is
|
|
629
|
+
// memoized on its inputs and revalidated by a stat token (mtime and size): a
|
|
630
|
+
// turn where nothing changed costs a handful of stats instead of re-reading and
|
|
631
|
+
// re-recursing every @import. A brand-new file satisfying a previously missing
|
|
632
|
+
// @import is picked up when any recorded stat token moves (or next session),
|
|
633
|
+
// which is already fresher than Claude, which loads context once at session start.
|
|
634
|
+
let envCache: { cwd: string; managed: Record<string, unknown>; excludeGlobs: string[]; projectRoot: string } | undefined
|
|
635
|
+
let importMemo:
|
|
636
|
+
| {
|
|
637
|
+
key: string
|
|
638
|
+
extras: Array<{ path: string; content: string; dir: string }>
|
|
639
|
+
imported: ImportedFile[]
|
|
640
|
+
budget: ImportBudget
|
|
641
|
+
tokens: Array<[string, string]>
|
|
642
|
+
}
|
|
643
|
+
| undefined
|
|
644
|
+
// mtime plus size, so a same-mtime rewrite of a different length still invalidates.
|
|
645
|
+
const statToken = (file: string): string => {
|
|
646
|
+
const stat = fs.statSync(file)
|
|
647
|
+
return `${stat.mtimeMs}:${stat.size}`
|
|
648
|
+
}
|
|
649
|
+
const memoIsFresh = (memo: NonNullable<typeof importMemo>): boolean => {
|
|
650
|
+
try {
|
|
651
|
+
return memo.tokens.every(([file, token]) => statToken(file) === token)
|
|
652
|
+
} catch {
|
|
653
|
+
return false // a recorded file vanished: re-expand
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
// Memo lookup or recompute: a turn whose key matches the previous expansion and
|
|
657
|
+
// whose recorded stat tokens are all unchanged reuses that expansion outright;
|
|
658
|
+
// otherwise the imports are re-expanded and the memo (with a fresh revalidation
|
|
659
|
+
// set) is rebuilt for next turn.
|
|
660
|
+
const resolveImports = (
|
|
661
|
+
memoKey: string,
|
|
662
|
+
native: Array<{ path: string; content: string }>,
|
|
663
|
+
contextFiles: Array<{ path: string; content: string }>,
|
|
664
|
+
home: string,
|
|
665
|
+
cwd: string,
|
|
666
|
+
excluded: (absPath: string) => boolean,
|
|
667
|
+
): { extras: Array<{ path: string; content: string; dir: string }>; budget: ImportBudget; imported: ImportedFile[] } => {
|
|
668
|
+
if (importMemo?.key === memoKey && memoIsFresh(importMemo)) {
|
|
669
|
+
const { extras, budget, imported } = importMemo
|
|
670
|
+
return { extras, budget, imported }
|
|
671
|
+
}
|
|
672
|
+
// Seed with every loaded context file path, excluded ones included, so pi's own
|
|
673
|
+
// files are never re-imported and an excluded file cannot return as an import.
|
|
674
|
+
// The user, project-.claude and managed-file additions join the seed too, so a
|
|
675
|
+
// context file's @import cannot pull any of them in a second time.
|
|
676
|
+
const ownPaths = [...native, ...localContexts].map((file) => file.path)
|
|
677
|
+
if (userContext !== undefined) ownPaths.push(userContext.path)
|
|
678
|
+
if (projectDotClaude !== undefined) ownPaths.push(projectDotClaude.path)
|
|
679
|
+
ownPaths.push(managedClaudeMdPath())
|
|
680
|
+
const seenSet = new Set(realRoots(ownPaths))
|
|
681
|
+
|
|
682
|
+
// Claude's --add-dir memory loading, env-gated. The files join the seen set
|
|
683
|
+
// before import expansion so an @import cannot pull one in twice, and they get
|
|
684
|
+
// the same exclude and comment-strip treatment as native context files.
|
|
685
|
+
const addDirs = additionalDirsClaudeMdEnabled() ? parseAdditionalDirs(pi.getFlag?.('add-dir'), home, cwd) : []
|
|
686
|
+
const extras = additionalDirExtras(addDirs, seenSet, excluded, projectApproved)
|
|
687
|
+
|
|
688
|
+
// One budget for the whole run, so N context files cannot each spend a full one.
|
|
689
|
+
// Exclusion applies inside the recursion: an excluded @import is skipped before
|
|
690
|
+
// it is read, so its transitive imports never load and it spends no budget.
|
|
691
|
+
const budget = createImportBudget()
|
|
692
|
+
const imported = expandImports(contextFiles, extras, home, cwd, seenSet, excluded, budget)
|
|
693
|
+
|
|
694
|
+
// Revalidation set: every file the expansion read, plus each add-dir itself
|
|
695
|
+
// (a directory's mtime moves when a memory file is added or removed there).
|
|
696
|
+
const tokens: Array<[string, string]> = []
|
|
697
|
+
try {
|
|
698
|
+
for (const file of [...extras.map((extra) => extra.path), ...imported.map((entry) => entry.path)]) tokens.push([file, statToken(file)])
|
|
699
|
+
for (const dir of addDirs) tokens.push([dir, statToken(dir)])
|
|
700
|
+
importMemo = { key: memoKey, extras, budget, imported, tokens }
|
|
701
|
+
} catch {
|
|
702
|
+
importMemo = undefined // a file moved mid-expansion: just recompute next turn
|
|
703
|
+
}
|
|
704
|
+
return { extras, budget, imported }
|
|
705
|
+
}
|
|
706
|
+
|
|
510
707
|
// Claude's --add-dir. Only the memory-loading half is meaningful here: pi has
|
|
511
708
|
// no path-based permission system, so there is no access grant to mirror.
|
|
512
709
|
// Optional-called so the extension still wires under stub hosts without flags.
|
|
@@ -520,14 +717,31 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
520
717
|
// a reload anyway; a reload simply re-fires InstructionsLoaded once per file,
|
|
521
718
|
// which is fine, since a reload re-loads the instruction files.
|
|
522
719
|
announced.clear()
|
|
720
|
+
envCache = undefined
|
|
721
|
+
importMemo = undefined
|
|
722
|
+
localContexts = []
|
|
723
|
+
userContext = undefined
|
|
724
|
+
projectDotClaude = undefined
|
|
725
|
+
|
|
726
|
+
// ~/.claude/CLAUDE.md, Claude's user-scope memory. The user's own file, so no
|
|
727
|
+
// project approval is required; a missing file simply leaves it unset.
|
|
728
|
+
try {
|
|
729
|
+
const userClaudeMd = path.join(claudeConfigDir(os.homedir()), 'CLAUDE.md')
|
|
730
|
+
userContext = { path: userClaudeMd, content: fs.readFileSync(userClaudeMd, 'utf-8') }
|
|
731
|
+
} catch {
|
|
732
|
+
// no user CLAUDE.md
|
|
733
|
+
}
|
|
734
|
+
|
|
523
735
|
// CLAUDE.local.md is Claude Code's personal sidecar of CLAUDE.md; pi's own loader
|
|
524
736
|
// skips it. A cloned repo can ship one, so it is gated like other project config.
|
|
525
737
|
// Claude loads local context from the whole hierarchy above the working
|
|
526
738
|
// directory, ordered root down to cwd; the walk is bounded at the repository
|
|
527
|
-
// root like every other project-config search here.
|
|
528
|
-
|
|
739
|
+
// root like every other project-config search here. The project-scope alternate
|
|
740
|
+
// ./.claude/CLAUDE.md (nearest at or above cwd) is repo-controlled too, so both
|
|
741
|
+
// ride the one approval decision.
|
|
529
742
|
const candidates = ancestorFiles(ctx.cwd, 'CLAUDE.local.md')
|
|
530
|
-
|
|
743
|
+
const dotClaudeMd = findNearestFile(ctx.cwd, path.join('.claude', 'CLAUDE.md'))
|
|
744
|
+
if ((candidates.length > 0 || dotClaudeMd !== null) && (await isProjectApproved(ctx))) {
|
|
531
745
|
for (const candidate of candidates) {
|
|
532
746
|
try {
|
|
533
747
|
localContexts.push({ path: candidate, content: fs.readFileSync(candidate, 'utf-8') })
|
|
@@ -535,6 +749,13 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
535
749
|
// unreadable: treat as absent
|
|
536
750
|
}
|
|
537
751
|
}
|
|
752
|
+
if (dotClaudeMd !== null) {
|
|
753
|
+
try {
|
|
754
|
+
projectDotClaude = { path: dotClaudeMd, content: fs.readFileSync(dotClaudeMd, 'utf-8') }
|
|
755
|
+
} catch {
|
|
756
|
+
// unreadable: treat as absent
|
|
757
|
+
}
|
|
758
|
+
}
|
|
538
759
|
}
|
|
539
760
|
// Read after the local-context flow so an approval it just recorded is honored.
|
|
540
761
|
projectApproved = isProjectApprovedSilently(ctx)
|
|
@@ -545,10 +766,12 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
545
766
|
const cwd = event.systemPromptOptions?.cwd ?? process.cwd()
|
|
546
767
|
const native: Array<{ path: string; content: string }> = event.systemPromptOptions?.contextFiles ?? []
|
|
547
768
|
|
|
548
|
-
|
|
549
|
-
|
|
769
|
+
if (envCache?.cwd !== cwd) {
|
|
770
|
+
const managedNow = readManagedSettings()
|
|
771
|
+
envCache = { cwd, managed: managedNow, excludeGlobs: readClaudeMdExcludes(claudeMdExcludeFiles(cwd, home, projectApproved), managedNow), projectRoot: repoRoot(cwd) ?? cwd }
|
|
772
|
+
}
|
|
773
|
+
const { managed, excludeGlobs, projectRoot } = envCache
|
|
550
774
|
const excluded = (absPath: string): boolean => isExcludedPath(absPath, excludeGlobs, home)
|
|
551
|
-
const projectRoot = repoRoot(cwd) ?? cwd
|
|
552
775
|
|
|
553
776
|
// claudeMdExcludes drops an excluded file's block from the assembled prompt and
|
|
554
777
|
// from import expansion; surviving blocks get block-level comments stripped.
|
|
@@ -563,35 +786,45 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
563
786
|
announce({ file_path: file.path, memory_type: memoryTypeForPath(file.path, home, projectRoot), load_reason: 'session_start' })
|
|
564
787
|
}
|
|
565
788
|
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
//
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
prompt = withManagedBlock(prompt, instructionsBlock(MANAGED_CLAUDE_MD_PATH, managedClaudeMd))
|
|
572
|
-
changed = true
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
const keptLocals = localContexts.filter((local) => !excluded(local.path)).map((local) => ({ path: local.path, content: stripBlockComments(local.content) }))
|
|
576
|
-
const contextFiles = [...rewrite.kept, ...keptLocals]
|
|
789
|
+
// The user CLAUDE.md is the user's own file (no approval gate) but respects
|
|
790
|
+
// claudeMdExcludes and comment-stripping like every other file. It is kept here so it
|
|
791
|
+
// both gets its own block (via prependMemoryBlocks) and joins the import-expansion set
|
|
792
|
+
// below, so its @imports resolve.
|
|
793
|
+
const keptUser = userContext !== undefined && !excluded(userContext.path) ? { path: userContext.path, content: stripBlockComments(userContext.content) } : undefined
|
|
577
794
|
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
|
|
795
|
+
// Prepend the managed and user memory blocks (managed file, managed key, user), the
|
|
796
|
+
// blocks Claude loads ahead of pi's native project context. managedFile comes back
|
|
797
|
+
// because it also seeds the import memo key below.
|
|
798
|
+
const top = prependMemoryBlocks(prompt, changed, keptUser, managed, announce)
|
|
799
|
+
prompt = top.prompt
|
|
800
|
+
changed = top.changed
|
|
801
|
+
const managedFile = top.managedFile
|
|
581
802
|
|
|
582
|
-
|
|
583
|
-
// before import expansion so an @import cannot pull one in twice, and they get
|
|
584
|
-
// the same exclude and comment-strip treatment as native context files.
|
|
585
|
-
const addDirs = additionalDirsClaudeMdEnabled() ? parseAdditionalDirs(pi.getFlag?.('add-dir'), home, cwd) : []
|
|
586
|
-
const extras = additionalDirExtras(addDirs, seenSet, excluded, projectApproved)
|
|
587
|
-
|
|
588
|
-
// One budget for the whole run, so N context files cannot each spend a full one.
|
|
589
|
-
// Exclusion applies inside the recursion: an excluded @import is skipped before
|
|
590
|
-
// it is read, so its transitive imports never load and it spends no budget.
|
|
591
|
-
const budget = createImportBudget()
|
|
592
|
-
const imported = expandImports(contextFiles, extras, home, cwd, seenSet, excluded, budget)
|
|
803
|
+
const keptLocals = localContexts.filter((local) => !excluded(local.path)).map((local) => ({ path: local.path, content: stripBlockComments(local.content) }))
|
|
593
804
|
|
|
594
|
-
|
|
805
|
+
// ./.claude/CLAUDE.md, deduped against pi's native context so that if pi ever
|
|
806
|
+
// loads it too there is no double block, then exclude-checked and comment-stripped
|
|
807
|
+
// like the rest. Its @imports resolve at project roots (rootsForImporter).
|
|
808
|
+
const nativeReal = new Set(realRoots(native.map((file) => file.path)))
|
|
809
|
+
const [dotReal] = projectDotClaude !== undefined ? realRoots([projectDotClaude.path]) : []
|
|
810
|
+
const keptProjectDotClaude = projectDotClaude !== undefined && !(dotReal !== undefined && nativeReal.has(dotReal)) && !excluded(projectDotClaude.path) ? { path: projectDotClaude.path, content: stripBlockComments(projectDotClaude.content) } : undefined
|
|
811
|
+
|
|
812
|
+
// The user CLAUDE.md and ./.claude/CLAUDE.md join the import-expansion set so their
|
|
813
|
+
// @imports resolve (each at roots scoped to it, via rootsForImporter); their own
|
|
814
|
+
// bodies are placed separately, so expansion only surfaces what they import.
|
|
815
|
+
const contextFiles = [...rewrite.kept, ...(keptUser !== undefined ? [keptUser] : []), ...(keptProjectDotClaude !== undefined ? [keptProjectDotClaude] : []), ...keptLocals]
|
|
816
|
+
|
|
817
|
+
// Everything the expansion depends on, hashed: a turn whose inputs match the memo
|
|
818
|
+
// and whose recorded mtimes are unchanged reuses the previous expansion outright.
|
|
819
|
+
const addDirsRaw = additionalDirsClaudeMdEnabled() ? String(pi.getFlag?.('add-dir') ?? '') : ''
|
|
820
|
+
const memoKey = buildImportMemoKey({ cwd, home, projectApproved, addDirsRaw, excludeGlobs, native, localContexts, userContext, projectDotClaude, managedFile, contextFiles })
|
|
821
|
+
|
|
822
|
+
const { extras, budget, imported } = resolveImports(memoKey, native, contextFiles, home, cwd, excluded)
|
|
823
|
+
|
|
824
|
+
// Project memory precedes local memory, so the ./.claude/CLAUDE.md block leads the
|
|
825
|
+
// additions, ahead of the CLAUDE.local.md bodies.
|
|
826
|
+
let addition = projectContextAddition(keptProjectDotClaude, home, projectRoot, announce)
|
|
827
|
+
addition += localContextAddition(keptLocals, announce)
|
|
595
828
|
addition += additionalDirsAddition(extras, announce)
|
|
596
829
|
addition += importedAddition(imported, budget, home, projectRoot, announce)
|
|
597
830
|
if (!changed && addition.length === 0) return
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context Usage Command
|
|
3
|
+
*
|
|
4
|
+
* Claude Code's `/context` reports how much of the model's context window the
|
|
5
|
+
* current session occupies. This registers a `context` command that reads pi's
|
|
6
|
+
* live ContextUsage and renders a concise breakdown through notify. pi exposes
|
|
7
|
+
* three fields (tokens, contextWindow, percent); the used/window/free lines and
|
|
8
|
+
* the percentage are built from exactly those, with the model's window as a
|
|
9
|
+
* fallback when the usage snapshot does not carry one.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ContextUsage, ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
13
|
+
|
|
14
|
+
const fmt = (n: number): string => n.toLocaleString('en-US')
|
|
15
|
+
|
|
16
|
+
/** The breakdown text for a usage snapshot, or a friendly line when there is none.
|
|
17
|
+
* `tokens` is null right after a compaction (before the next response recounts), so
|
|
18
|
+
* that case reports a recalculating state instead of a bogus zero. */
|
|
19
|
+
export function formatContextUsage(usage: ContextUsage | undefined, modelWindow?: number): string {
|
|
20
|
+
if (!usage) {
|
|
21
|
+
return 'Context usage is not available yet. It appears once the model has responded (and resets right after a compaction).'
|
|
22
|
+
}
|
|
23
|
+
const window = usage.contextWindow || modelWindow || 0
|
|
24
|
+
if (usage.tokens === null) {
|
|
25
|
+
const tail = window > 0 ? ` Window: ${fmt(window)} tokens.` : ''
|
|
26
|
+
return `Context usage: recalculating the token count (e.g. right after a compaction).${tail}`
|
|
27
|
+
}
|
|
28
|
+
const tokens = usage.tokens
|
|
29
|
+
const percent = usage.percent ?? (window > 0 ? (tokens / window) * 100 : null)
|
|
30
|
+
const percentSuffix = percent === null ? '' : ` (${percent.toFixed(1)}%)`
|
|
31
|
+
const lines = ['Context usage', ` Used: ${fmt(tokens)} tokens${percentSuffix}`]
|
|
32
|
+
if (window > 0) {
|
|
33
|
+
lines.push(` Window: ${fmt(window)} tokens`, ` Free: ${fmt(Math.max(window - tokens, 0))} tokens`)
|
|
34
|
+
}
|
|
35
|
+
return lines.join('\n')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default function contextUsageExtension(pi: ExtensionAPI) {
|
|
39
|
+
pi.registerCommand('context', {
|
|
40
|
+
description: 'Show how much of the model context window this session is using',
|
|
41
|
+
handler: async (_args, ctx) => {
|
|
42
|
+
ctx.ui.notify(formatContextUsage(ctx.getContextUsage(), ctx.model?.contextWindow), 'info')
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* settings.json `env` injection.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code lets any settings scope carry an `env` object whose keys are exported
|
|
5
|
+
* into the session's environment. This extension applies that chain to process.env:
|
|
6
|
+
* managed-settings.json (enterprise policy), ~/.claude/settings.json (user), and the
|
|
7
|
+
* project's .claude/settings.json plus settings.local.json.
|
|
8
|
+
*
|
|
9
|
+
* Two things run this. The factory body applies managed + user immediately (as pi
|
|
10
|
+
* loads extensions), so those variables are present before the first turn; a session
|
|
11
|
+
* that never approves a project still gets them. session_start refreshes and, only
|
|
12
|
+
* when the project is approved, folds in the project scope. The project scope stays
|
|
13
|
+
* approval-gated on purpose: a checked-out repository's env can redirect providers
|
|
14
|
+
* (ANTHROPIC_BASE_URL and friends), so an untrusted repo must not reach process.env.
|
|
15
|
+
*
|
|
16
|
+
* Precedence is per key, managed > user > project, matching Claude's merge: a scope
|
|
17
|
+
* only supplies keys it names and never wipes another scope's keys. Values must be
|
|
18
|
+
* strings; a number or boolean is coerced via String, anything else is skipped.
|
|
19
|
+
*
|
|
20
|
+
* A variable already present in the real environment that this module did not set is
|
|
21
|
+
* left untouched for the user and project scopes: a shell `export` outranks them. The
|
|
22
|
+
* managed scope is the exception: an org policy env must win over an ambient export, so
|
|
23
|
+
* a managed key overwrites a preexisting shell value. The keys this module sets are
|
|
24
|
+
* recorded so a later refresh can update them without clobbering unrelated env, and a
|
|
25
|
+
* key an earlier apply set that the current merge no longer defines is unset (deleted
|
|
26
|
+
* from process.env), so an approved project's env cannot leak into a later session or
|
|
27
|
+
* project that does not define it. A managed overwrite of a shell var is owned like any
|
|
28
|
+
* other set key; its original shell value cannot be restored, so on unset it is deleted.
|
|
29
|
+
*
|
|
30
|
+
* Docs: https://code.claude.com/docs/en/settings.md
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import * as fs from 'node:fs'
|
|
34
|
+
import * as os from 'node:os'
|
|
35
|
+
import * as path from 'node:path'
|
|
36
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
37
|
+
|
|
38
|
+
import { claudeConfigDir } from './internal/config-dir.js'
|
|
39
|
+
import { readManagedSettings } from './internal/managed-settings.js'
|
|
40
|
+
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
41
|
+
import { findNearestFile } from './internal/project-root.js'
|
|
42
|
+
|
|
43
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
44
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The `env` object of one settings scope, coerced to string values. A string is kept
|
|
48
|
+
* as-is, a number or boolean becomes its String() form, and anything else (object,
|
|
49
|
+
* array, null) is dropped, matching Claude which only injects string-valued env. */
|
|
50
|
+
export function envFromSettings(settings: unknown): Record<string, string> {
|
|
51
|
+
const out: Record<string, string> = {}
|
|
52
|
+
if (!isRecord(settings) || !isRecord(settings.env)) return out
|
|
53
|
+
for (const [key, value] of Object.entries(settings.env)) {
|
|
54
|
+
if (typeof value === 'string') out[key] = value
|
|
55
|
+
else if (typeof value === 'number' || typeof value === 'boolean') out[key] = String(value)
|
|
56
|
+
}
|
|
57
|
+
return out
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Merge the three env scopes with Claude's per-key precedence managed > user >
|
|
61
|
+
* project: lower scopes are laid down first and higher ones overlay, so each key
|
|
62
|
+
* takes its highest-precedence value and no scope wipes another's keys. */
|
|
63
|
+
export function mergeEnvScopes(managed: Record<string, string>, user: Record<string, string>, project: Record<string, string>): Record<string, string> {
|
|
64
|
+
return { ...project, ...user, ...managed }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Assign the merged env into `env`, recording each key set into `owned`. A key already
|
|
68
|
+
* present that this module did not set (a shell export) is left untouched for user and
|
|
69
|
+
* project keys; a `managedKeys` entry overwrites it (an org policy outranks the shell).
|
|
70
|
+
* A key this module set before is updated. A previously-owned key that the current
|
|
71
|
+
* `merged` no longer defines is unset (deleted from `env` and `owned`), so an approved
|
|
72
|
+
* project's env cannot leak into a later apply that does not define it. */
|
|
73
|
+
export function applyEnvSettings(merged: Record<string, string>, env: NodeJS.ProcessEnv, owned: Set<string>, managedKeys: ReadonlySet<string> = new Set()): void {
|
|
74
|
+
// Unset any key an earlier apply set that the current merge dropped. Iterate a copy
|
|
75
|
+
// since `owned` is mutated. A shell export this module never owned is left in place.
|
|
76
|
+
for (const key of Array.from(owned)) {
|
|
77
|
+
if (!(key in merged)) {
|
|
78
|
+
delete env[key]
|
|
79
|
+
owned.delete(key)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (const [key, value] of Object.entries(merged)) {
|
|
83
|
+
// A shell export outranks user/project (skip), but a managed key outranks even the
|
|
84
|
+
// shell. Once owned, updates always apply. A managed overwrite is owned like any
|
|
85
|
+
// set key: its shell value is gone and cannot be restored, so on unset it is deleted.
|
|
86
|
+
if (key in env && !owned.has(key) && !managedKeys.has(key)) continue
|
|
87
|
+
env[key] = value
|
|
88
|
+
owned.add(key)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readSettingsFile(file: string): Record<string, unknown> {
|
|
93
|
+
try {
|
|
94
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
95
|
+
if (isRecord(parsed)) return parsed
|
|
96
|
+
} catch {
|
|
97
|
+
// missing or invalid file: no env from this scope
|
|
98
|
+
}
|
|
99
|
+
return {}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The user scope's env: ~/.claude/settings.json (relocated by CLAUDE_CONFIG_DIR). */
|
|
103
|
+
function userEnv(home: string): Record<string, string> {
|
|
104
|
+
return envFromSettings(readSettingsFile(path.join(claudeConfigDir(home), 'settings.json')))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The project scope's env: .claude/settings.json with settings.local.json overlaid,
|
|
108
|
+
* each the nearest of its name at or above cwd (matching the hooks settings chain). */
|
|
109
|
+
function projectEnv(cwd: string): Record<string, string> {
|
|
110
|
+
const base = envFromSettings(readSettingsFile(findNearestFile(cwd, path.join('.claude', 'settings.json')) ?? path.join(cwd, '.claude', 'settings.json')))
|
|
111
|
+
const local = envFromSettings(readSettingsFile(findNearestFile(cwd, path.join('.claude', 'settings.local.json')) ?? path.join(cwd, '.claude', 'settings.local.json')))
|
|
112
|
+
return { ...base, ...local }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export default function envSettingsExtension(pi: ExtensionAPI) {
|
|
116
|
+
const owned = new Set<string>()
|
|
117
|
+
|
|
118
|
+
const apply = (home: string, project: Record<string, string>): void => {
|
|
119
|
+
const managed = envFromSettings(readManagedSettings())
|
|
120
|
+
applyEnvSettings(mergeEnvScopes(managed, userEnv(home), project), process.env, owned, new Set(Object.keys(managed)))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Factory time: managed + user only. Approval needs the session ctx, so the project
|
|
124
|
+
// scope waits for session_start; running here means these vars land before the first turn.
|
|
125
|
+
apply(os.homedir(), {})
|
|
126
|
+
|
|
127
|
+
pi.on('session_start', async (_event, ctx: ExtensionContext) => {
|
|
128
|
+
apply(os.homedir(), isProjectApprovedSilently(ctx) ? projectEnv(ctx.cwd) : {})
|
|
129
|
+
})
|
|
130
|
+
}
|