pi-code 1.0.9 → 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.
@@ -14,11 +14,12 @@ import * as path from 'node:path'
14
14
  import { StringEnum } from '@earendil-works/pi-ai'
15
15
  import { type ExtensionAPI, withFileMutationQueue } from '@earendil-works/pi-coding-agent'
16
16
  import { Type } from 'typebox'
17
+ import { claudeConfigDir } from './internal/config-dir.js'
17
18
  import { capForContext } from './internal/output-guard.js'
18
19
  import { isProjectApprovedSilently } from './internal/project-approval.js'
19
20
  import { findNearestFile, repoRoot } from './internal/project-root.js'
20
21
 
21
- const INDEX_FILE = 'MEMORY.md'
22
+ export const INDEX_FILE = 'MEMORY.md'
22
23
 
23
24
  /** Claude loads the first 200 lines or 25KB of the memory index at startup. */
24
25
  export const INDEX_MAX_LINES = 200
@@ -282,7 +283,7 @@ function writeIndex(indexPath: string, content: string): void {
282
283
  * approved, since a project's `autoMemoryDirectory` is honored under the same trust
283
284
  * rule as hooks in settings files. Later files win. */
284
285
  export function memorySettingsFiles(cwd: string, home: string, approved: boolean): string[] {
285
- const files = [path.join(home, '.claude', 'settings.json')]
286
+ const files = [path.join(claudeConfigDir(home), 'settings.json')]
286
287
  if (!approved) return files
287
288
  for (const name of ['settings.json', 'settings.local.json']) {
288
289
  files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
@@ -306,6 +307,40 @@ export function readMemorySettings(files: string[]): { autoMemoryEnabled?: unkno
306
307
  return merged
307
308
  }
308
309
 
310
+ /** Write `autoMemoryEnabled` into the user settings file, preserving every other key
311
+ * and creating the file and its config directory when absent. Claude's /memory toggle
312
+ * writes to the user scope (relocated by CLAUDE_CONFIG_DIR); the value takes effect from
313
+ * the next session start, which is where autoMemoryEnabled is read.
314
+ *
315
+ * An absent file starts from an empty object so the toggle still lands. A file that is
316
+ * PRESENT but unparseable is refused, not overwritten: clobbering it would destroy the
317
+ * user's hooks, env and permissions config. The caller surfaces the returned failure. */
318
+ export function setAutoMemoryEnabledSetting(home: string, value: boolean): { ok: true } | { ok: false; error: string } {
319
+ const dir = claudeConfigDir(home)
320
+ const file = path.join(dir, 'settings.json')
321
+ let current: Record<string, unknown> = {}
322
+ let raw: string | undefined
323
+ try {
324
+ raw = fs.readFileSync(file, 'utf-8')
325
+ } catch (error) {
326
+ // Only a missing file means start fresh; any other read failure propagates.
327
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
328
+ }
329
+ if (raw !== undefined) {
330
+ try {
331
+ const parsed = JSON.parse(raw)
332
+ if (parsed !== null && typeof parsed === 'object') current = parsed as Record<string, unknown>
333
+ } catch {
334
+ // Present but unparseable: refuse rather than overwrite the user's config.
335
+ return { ok: false, error: 'settings.json is not valid JSON; not modified' }
336
+ }
337
+ }
338
+ current.autoMemoryEnabled = value
339
+ fs.mkdirSync(dir, { recursive: true })
340
+ fs.writeFileSync(file, `${JSON.stringify(current, null, 2)}\n`)
341
+ return { ok: true }
342
+ }
343
+
309
344
  export default function memoryExtension(pi: ExtensionAPI) {
310
345
  let dir = memoryDir(process.cwd())
311
346
  let enabled = true
@@ -409,4 +444,53 @@ export default function memoryExtension(pi: ExtensionAPI) {
409
444
  return { content: [{ type: 'text' as const, text: index.trim() || 'No memories saved for this project yet.' }], details: {} }
410
445
  },
411
446
  })
447
+
448
+ // Claude's /memory lists the memory locations and toggles auto memory. pi has no
449
+ // editor seam, so the paths are printed rather than opened. The listing reads the
450
+ // settings chain live so it reflects a toggle written in the same session.
451
+ pi.registerCommand('memory', {
452
+ description: 'Show memory file locations and toggle auto memory (/memory [on|off])',
453
+ handler: async (args, ctx) => {
454
+ const home = os.homedir()
455
+ const arg = args.trim().toLowerCase()
456
+
457
+ if (arg === 'on' || arg === 'off') {
458
+ const next = arg === 'on'
459
+ let result: { ok: true } | { ok: false; error: string }
460
+ try {
461
+ result = setAutoMemoryEnabledSetting(home, next)
462
+ } catch (error) {
463
+ ctx.ui.notify(`Could not update auto memory: ${error instanceof Error ? error.message : String(error)}`, 'error')
464
+ return
465
+ }
466
+ if (!result.ok) {
467
+ ctx.ui.notify(result.error, 'error')
468
+ return
469
+ }
470
+ ctx.ui.notify(`Auto memory ${next ? 'enabled' : 'disabled'} in ${path.join(claudeConfigDir(home), 'settings.json')} (applies next session).`, 'info')
471
+ return
472
+ }
473
+
474
+ if (arg.length > 0) {
475
+ ctx.ui.notify('Usage: /memory [on|off]', 'error')
476
+ return
477
+ }
478
+
479
+ const approved = isProjectApprovedSilently(ctx)
480
+ const settings = readMemorySettings(memorySettingsFiles(ctx.cwd, home, approved))
481
+ const isEnabled = autoMemoryEnabled(settings.autoMemoryEnabled, process.env)
482
+ const override = typeof settings.autoMemoryDirectory === 'string' ? settings.autoMemoryDirectory : undefined
483
+ const store = resolveMemoryDir(ctx.cwd, override)
484
+ const lines = [
485
+ 'Memory',
486
+ ` Auto memory: ${isEnabled ? 'on' : 'off'}`,
487
+ ` Store: ${store}`,
488
+ ` Index: ${path.join(store, INDEX_FILE)}`,
489
+ ` User memory (CLAUDE.md): ${path.join(home, '.claude', 'CLAUDE.md')}`,
490
+ ` Project memory (CLAUDE.md): ${path.join(ctx.cwd, 'CLAUDE.md')}`,
491
+ 'Toggle with /memory on or /memory off.',
492
+ ]
493
+ ctx.ui.notify(lines.join('\n'), 'info')
494
+ },
495
+ })
412
496
  }
@@ -21,6 +21,8 @@ import * as os from 'node:os'
21
21
  import * as path from 'node:path'
22
22
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
23
23
 
24
+ import { claudeConfigDir } from './internal/config-dir.js'
25
+
24
26
  /** How a finished turn is announced, from Claude's `preferredNotifChannel`. */
25
27
  export type NotifChannel = 'desktop' | 'bell' | 'both' | 'off'
26
28
 
@@ -55,7 +57,7 @@ export function isAway(lastInputAt: number | undefined, now: number, thresholdMs
55
57
  * or change your notifications. */
56
58
  function readPreferredNotifChannel(home: string): unknown {
57
59
  try {
58
- const settings = JSON.parse(fs.readFileSync(path.join(home, '.claude', 'settings.json'), 'utf-8'))
60
+ const settings = JSON.parse(fs.readFileSync(path.join(claudeConfigDir(home), 'settings.json'), 'utf-8'))
59
61
  return settings?.preferredNotifChannel
60
62
  } catch {
61
63
  return undefined
@@ -24,6 +24,7 @@ import * as os from 'node:os'
24
24
  import * as path from 'node:path'
25
25
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
26
26
 
27
+ import { claudeConfigDir } from './internal/config-dir.js'
27
28
  import { installedPlugins } from './internal/plugins.js'
28
29
  import { isProjectApproved } from './internal/project-approval.js'
29
30
  import { findNearestDir, findNearestFile } from './internal/project-root.js'
@@ -82,7 +83,7 @@ function isDirectory(target: string): boolean {
82
83
  * verbatim into the system prompt.
83
84
  */
84
85
  export function styleDirs(cwd: string, home: string, trusted: boolean): string[] {
85
- const dirs = [path.join(home, '.claude', 'output-styles')]
86
+ const dirs = [path.join(claudeConfigDir(home), 'output-styles')]
86
87
  if (trusted) dirs.push(findNearestDir(cwd, path.join('.claude', 'output-styles')) ?? path.join(cwd, '.claude', 'output-styles'))
87
88
  return dirs.filter((dir) => isDirectory(dir))
88
89
  }
@@ -129,7 +130,7 @@ export function loadStyles(dirs: string[]): OutputStyle[] {
129
130
  /** Settings files that carry `outputStyle`. Project settings apply only when trusted,
130
131
  * each the nearest of its name at or above cwd, as the hooks settings chain reads. */
131
132
  export function settingsFiles(cwd: string, home: string, trusted: boolean): string[] {
132
- const files = [path.join(home, '.claude', 'settings.json')]
133
+ const files = [path.join(claudeConfigDir(home), 'settings.json')]
133
134
  if (!trusted) return files
134
135
  for (const name of ['settings.json', 'settings.local.json']) {
135
136
  files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
@@ -15,6 +15,7 @@ import * as os from 'node:os'
15
15
  import * as path from 'node:path'
16
16
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
17
17
 
18
+ import { claudeConfigDir } from './internal/config-dir.js'
18
19
  import { installedPlugins } from './internal/plugins.js'
19
20
  import { isProjectApprovedSilently } from './internal/project-approval.js'
20
21
  import { findNearestDir } from './internal/project-root.js'
@@ -33,7 +34,7 @@ function isDirectory(target: string): boolean {
33
34
  * name and description to the model, so an untrusted repository would otherwise get
34
35
  * text into the prompt without the user ever agreeing to load its config. */
35
36
  export function skillDirs(cwd: string, home: string, trusted: boolean): string[] {
36
- const candidates = [path.join(home, '.claude', 'skills')]
37
+ const candidates = [path.join(claudeConfigDir(home), 'skills')]
37
38
  // Enabled plugins contribute their skills directories. pi's loader names a
38
39
  // skill by its directory, so a plugin skill registers without Claude's
39
40
  // /plugin: prefix; a rename-free approximation, disclosed in the README.
@@ -11,6 +11,7 @@ import { getAgentDir, parseFrontmatter, stripFrontmatter } from '@earendil-works
11
11
  // Claude field, and `--tools` is an exact-name allowlist, so a name pi has no tool for
12
12
  // is not merely ignored, it narrows the child's registry.
13
13
  import { parseToolGrants } from '../internal/command-file.js'
14
+ import { claudeConfigDir } from '../internal/config-dir.js'
14
15
  import { installedPlugins } from '../internal/plugins.js'
15
16
  import { findNearestDir } from '../internal/project-root.js'
16
17
 
@@ -291,7 +292,7 @@ function pluginAgentDirs(home: string): string[] {
291
292
 
292
293
  export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
293
294
  const userDir = path.join(getAgentDir(), 'agents')
294
- const claudeUserDir = path.join(os.homedir(), '.claude', 'agents')
295
+ const claudeUserDir = path.join(claudeConfigDir(os.homedir()), 'agents')
295
296
  const projectPiDir = findNearestDir(cwd, path.join('.pi', 'agents'))
296
297
  const projectClaudeDir = findNearestDir(cwd, path.join('.claude', 'agents'))
297
298
 
@@ -24,6 +24,7 @@ import { type ExtensionAPI, type ExtensionContext, getMarkdownTheme, type Theme,
24
24
  import { Container, Markdown, Spacer, Text } from '@earendil-works/pi-tui'
25
25
  import { type Static, Type } from 'typebox'
26
26
  import { type AgentRunRequest, setAgentRunner } from '../internal/agent-run.js'
27
+ import { claudeConfigDir } from '../internal/config-dir.js'
27
28
  import { capForContext } from '../internal/output-guard.js'
28
29
  import { isProjectApproved, isProjectApprovedSilently } from '../internal/project-approval.js'
29
30
  import { repoRoot } from '../internal/project-root.js'
@@ -697,7 +698,7 @@ export function agentMemoryDir(scope: AgentMemoryScope, name: string, cwd: strin
697
698
  const sanitized = name.replace(/[^\w.-]+/g, '_')
698
699
  // A name of only dots ('.', '..') survives the character filter but still traverses.
699
700
  const segment = /^\.+$/.test(sanitized) ? '_' : sanitized
700
- if (scope === 'user') return path.join(home, '.claude', 'agent-memory', segment)
701
+ if (scope === 'user') return path.join(claudeConfigDir(home), 'agent-memory', segment)
701
702
  const root = repoRoot(cwd) ?? cwd
702
703
  return path.join(root, '.claude', scope === 'project' ? 'agent-memory' : 'agent-memory-local', segment)
703
704
  }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Thinking Keyword Escalation
3
+ *
4
+ * Claude Code raises reasoning effort for a single turn when the prompt carries a
5
+ * think keyword: `ultrathink` asks for the maximum, `think hard`/`think harder` for
6
+ * a high level, and a bare `think` for a medium one. The word stays in the prompt
7
+ * (the input is observed, never consumed or transformed), the escalation only ever
8
+ * raises the level, and the prior level is restored once the turn settles, mirroring
9
+ * how commands.ts restores a per-command model override on agent_settled.
10
+ */
11
+
12
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
13
+
14
+ // The pi ThinkingLevel union, taken from the setter's parameter so it tracks the SDK.
15
+ type ThinkingLevel = Parameters<ExtensionAPI['setThinkingLevel']>[0]
16
+
17
+ /** Lowest to highest, matching pi's ThinkingLevel union; rank is the index. */
18
+ const THINKING_ORDER: readonly ThinkingLevel[] = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
19
+
20
+ export function thinkingRank(level: ThinkingLevel): number {
21
+ const i = THINKING_ORDER.indexOf(level)
22
+ return Math.max(i, 0)
23
+ }
24
+
25
+ /** The reasoning level a prompt requests through Claude's think keywords, or undefined
26
+ * when it names none. Checked most-specific first so `think harder` does not fall
27
+ * through to the bare-`think` branch, and on word boundaries so `rethink`/`thinking`
28
+ * and the whole word `ultrathink` never trip the bare match. */
29
+ export function requestedThinkingLevel(text: string): ThinkingLevel | undefined {
30
+ if (/\bultrathink\b/i.test(text)) return 'max'
31
+ if (/\bthink harder\b/i.test(text) || /\bthink hard\b/i.test(text)) return 'high'
32
+ if (/\bthink\b/i.test(text)) return 'medium'
33
+ return undefined
34
+ }
35
+
36
+ export default function thinkingExtension(pi: ExtensionAPI) {
37
+ // The level to restore once the escalated turn settles, captured the first time a
38
+ // turn escalates so back-to-back keywords in one turn still restore the original.
39
+ // Cleared on agent_settled, the run's true end past any retry/compaction/Stop
40
+ // continuation, the same clearing point commands.ts uses for its model override.
41
+ let pendingRestore: ThinkingLevel | undefined
42
+ // The level this extension last escalated to. The restore is conditional on the level
43
+ // still being this target at settle: commands.ts also restores an `effort:` override
44
+ // on agent_settled, so both fire on the same event. Keying the restore on the target
45
+ // makes the outcome order-independent: if a command's restore (or a manual change)
46
+ // already moved the level, thinking stands down instead of clobbering it.
47
+ let pendingTarget: ThinkingLevel | undefined
48
+
49
+ pi.on('input', (event, ctx) => {
50
+ // Only genuine user input escalates. sendUserMessage emits an input event with
51
+ // source 'extension' (a subagent prompt, a command body replayed through it); a
52
+ // think keyword the user did not type must not escalate, mirroring hooks.ts's guard.
53
+ if (event.source === 'extension') return
54
+ const target = requestedThinkingLevel(event.text)
55
+ if (!target) return
56
+ const current = pi.getThinkingLevel?.() ?? ctx.thinkingLevel ?? 'off'
57
+ // A keyword only raises reasoning: leave a level already at or above the target.
58
+ if (thinkingRank(current) >= thinkingRank(target)) return
59
+ pendingRestore = pendingRestore ?? current
60
+ pendingTarget = target
61
+ pi.setThinkingLevel?.(target)
62
+ // Return nothing so the input is neither consumed nor transformed: Claude keeps
63
+ // the keyword in the prompt.
64
+ })
65
+
66
+ pi.on('agent_settled', () => {
67
+ if (pendingRestore === undefined) return
68
+ const restore = pendingRestore
69
+ const target = pendingTarget
70
+ pendingRestore = undefined
71
+ pendingTarget = undefined
72
+ // Restore only if nothing else moved the level since this extension set it. If a
73
+ // command's effort restore or the user's manual change already took over (current
74
+ // no longer equals our target), leave that value in place and stand down. When the
75
+ // level cannot be read, restore unconditionally, the prior best-effort behavior.
76
+ const current = pi.getThinkingLevel?.()
77
+ if (current !== undefined && current !== target) return
78
+ pi.setThinkingLevel?.(restore)
79
+ })
80
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.9",
3
+ "version": "1.0.10",
4
4
  "description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, and subagents",
5
5
  "keywords": [
6
6
  "pi",