pi-code 1.0.70 → 1.0.72
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/commands.ts
CHANGED
|
@@ -52,6 +52,7 @@ import { managedSettingsFile, readManagedSettings } from './internal/managed-set
|
|
|
52
52
|
import { findModel } from './internal/model-lookup.js'
|
|
53
53
|
import { capForContext } from './internal/output-guard.js'
|
|
54
54
|
import { matchesPathRules } from './internal/path-rules.js'
|
|
55
|
+
import { isPlanModeState, PLAN_MODE_CHANNEL } from './internal/plan-mode-state.js'
|
|
55
56
|
import { type InstalledPlugin, installedPlugins, pluginComponentPath } from './internal/plugins.js'
|
|
56
57
|
import { isProjectApproved } from './internal/project-approval.js'
|
|
57
58
|
import { ancestorDirs, repoRoot } from './internal/project-root.js'
|
|
@@ -319,6 +320,9 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
319
320
|
let projectApproved = false
|
|
320
321
|
/** Tool set to put back once the run a restricted command drove has ended. */
|
|
321
322
|
let pendingRestore: string[] | undefined
|
|
323
|
+
/** The tools this run's restriction took out of the set. Put back as a delta on whatever is
|
|
324
|
+
* active by then: overwriting with the snapshot undid every change made meanwhile. */
|
|
325
|
+
let tookAway = new Set<string>()
|
|
322
326
|
/** `Bash(...)` scopes enforced while that run lasts; lifted with the restriction. */
|
|
323
327
|
let pendingBashRules: string[] | undefined
|
|
324
328
|
/** `WebFetch(domain:...)` scopes enforced the same way. */
|
|
@@ -335,12 +339,16 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
335
339
|
// setModel can reject (e.g. auth resolution fails), and a floated rejection would
|
|
336
340
|
// escape as unhandled; surface it as a no-op instead of leaving the session silently
|
|
337
341
|
// on the command's override model.
|
|
338
|
-
set: (model) => {
|
|
342
|
+
set: async (model) => {
|
|
339
343
|
// A refused switch leaves the turn on the session model rather than the one the
|
|
340
|
-
// command named, and the reply gives no sign of it, so the refusal is reported.
|
|
341
|
-
|
|
344
|
+
// command named, and the reply gives no sign of it, so the refusal is reported. The
|
|
345
|
+
// promise never rejects, so a caller that does not await it (agent_settled) cannot
|
|
346
|
+
// float a rejection; the shutdown handler awaits it so the exit cannot outrun it.
|
|
347
|
+
try {
|
|
348
|
+
await pi.setModel(model as Parameters<typeof pi.setModel>[0])
|
|
349
|
+
} catch (error) {
|
|
342
350
|
console.warn(`pi-code-commands: could not switch to ${typeof model === 'object' && model !== null && 'id' in model ? String((model as { id: unknown }).id) : String(model)}: ${errorMessage(error)}`)
|
|
343
|
-
}
|
|
351
|
+
}
|
|
344
352
|
},
|
|
345
353
|
})
|
|
346
354
|
/** The thinking level to restore after a command's `effort:` override drove its run,
|
|
@@ -358,17 +366,26 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
358
366
|
// agent_settled fires exactly once, after the run has fully settled and no such
|
|
359
367
|
// continuation remains, which is the grant's true clearing point.
|
|
360
368
|
pi.on('agent_settled', async () => {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
369
|
+
void modelOverride.settle()
|
|
370
|
+
void effortOverride.settle()
|
|
371
|
+
liftToolScope()
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
// "Execute the plan" continues the very run a scoped command started, so the scope it set
|
|
375
|
+
// for planning would judge every call of the execution and the tools it narrowed away
|
|
376
|
+
// would stay gone. Leaving plan mode is the point the user approved more than the command
|
|
377
|
+
// asked for; the model and effort overrides still last until the run settles.
|
|
378
|
+
pi.events?.on(PLAN_MODE_CHANNEL, (data) => {
|
|
379
|
+
if (isPlanModeState(data) && !data.active) liftToolScope()
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
// agent_settled never fires when pi is quit mid-run (double Ctrl+C, Ctrl+D, a closed
|
|
383
|
+
// terminal, SIGTERM), and the switch is already in the transcript: `pi -c` resumed on the
|
|
384
|
+
// command's override model at its raised level. A run that settled has nothing armed, so
|
|
385
|
+
// this restores nothing twice.
|
|
386
|
+
pi.on('session_shutdown', async () => {
|
|
387
|
+
await modelOverride.settle()
|
|
388
|
+
await effortOverride.settle()
|
|
372
389
|
})
|
|
373
390
|
|
|
374
391
|
// The active-tool set has no argument dimension, so a scoped grant hands the turn
|
|
@@ -414,6 +431,32 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
414
431
|
return original
|
|
415
432
|
}
|
|
416
433
|
|
|
434
|
+
/** Record what the restriction just applied took out, against the set captured before it. */
|
|
435
|
+
function noteTakenAway(): void {
|
|
436
|
+
if (!pendingRestore) return
|
|
437
|
+
const active = new Set(pi.getActiveTools())
|
|
438
|
+
tookAway = new Set(pendingRestore.filter((tool) => !active.has(tool)))
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** End the run's tool restriction: drop its scopes and put back what it took away, as a
|
|
442
|
+
* delta on the set in force. A snapshot overwrite undid whatever else changed the set
|
|
443
|
+
* meanwhile: plan mode's Execute (the session finished read-only, plan mode off, with edit
|
|
444
|
+
* and write gone) and a tool registered during the run (dropped until a /reload). */
|
|
445
|
+
function liftToolScope(): void {
|
|
446
|
+
pendingBashRules = undefined
|
|
447
|
+
pendingDomainRules = undefined
|
|
448
|
+
pendingAgentRules = undefined
|
|
449
|
+
pendingSkillRules = undefined
|
|
450
|
+
pendingPathRules = undefined
|
|
451
|
+
const original = pendingRestore
|
|
452
|
+
if (!original) return
|
|
453
|
+
const active = pi.getActiveTools()
|
|
454
|
+
const keep = new Set([...active, ...tookAway])
|
|
455
|
+
pi.setActiveTools([...original.filter((tool) => keep.has(tool)), ...active.filter((tool) => !original.includes(tool))])
|
|
456
|
+
pendingRestore = undefined
|
|
457
|
+
tookAway = new Set()
|
|
458
|
+
}
|
|
459
|
+
|
|
417
460
|
/** Apply a command's allowed-tools to the run it drives: intersect with the tools
|
|
418
461
|
* pi actually has, keep the bash and path scopes for the tool_call guard, and let
|
|
419
462
|
* agent_settled restore the previous set. */
|
|
@@ -429,6 +472,7 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
429
472
|
// intersects to nothing named only tools pi has none of: that restriction cannot
|
|
430
473
|
// be expressed, and applying it as "no tools" is not what the command asked for.
|
|
431
474
|
if (granted.length > 0 || allowed.length === 0) pi.setActiveTools(granted)
|
|
475
|
+
noteTakenAway()
|
|
432
476
|
// The latest restricted command speaks for the turn: a later unscoped grant
|
|
433
477
|
// lifts an earlier command's scopes rather than stacking under them. Rules get
|
|
434
478
|
// the same ${CLAUDE_*} substitution as the body, so a rule can name a bundled
|
|
@@ -447,6 +491,7 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
447
491
|
if (!disallowed || disallowed.length === 0) return
|
|
448
492
|
captureRestorePoint()
|
|
449
493
|
pi.setActiveTools(pi.getActiveTools().filter((tool) => !disallowed.includes(tool)))
|
|
494
|
+
noteTakenAway()
|
|
450
495
|
}
|
|
451
496
|
|
|
452
497
|
/** Claude's `model:` frontmatter overrides the model for this run only, then the
|
|
@@ -223,6 +223,12 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
223
223
|
let managedHooksOnly = false
|
|
224
224
|
/** Skill hooks registered this session, re-applied when a settings edit reloads. */
|
|
225
225
|
const registeredSkillHooks: Array<{ skillName: string; hooks: Record<string, unknown> }> = []
|
|
226
|
+
/** Whether this process is a subagent's child. Claude runs settings hooks inside a
|
|
227
|
+
* subagent for tool events, but Stop is the main agent's, a subagent completes with
|
|
228
|
+
* SubagentStop, and a subagent run is neither a session (SessionStart, SessionEnd) nor
|
|
229
|
+
* a prompt the user submitted (UserPromptSubmit). The child is a full pi process loading
|
|
230
|
+
* the same settings, so without this every one of them fired in it. */
|
|
231
|
+
const inSubagentChild = (): boolean => process.env.PI_CODE_SUBAGENT === '1'
|
|
226
232
|
/** Stops the settings watcher of the previous session. */
|
|
227
233
|
let disposeSettingsWatch: () => void = () => {}
|
|
228
234
|
/** Claude's disableAllHooks escape hatch was set somewhere in the honored chain. */
|
|
@@ -466,7 +472,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
466
472
|
pendingSessionContext = []
|
|
467
473
|
// "reload" re-fires in-process with the same conversation and would double-run hooks;
|
|
468
474
|
// a fork is a genuine session begin, which Claude reports as source "fork".
|
|
469
|
-
if (event.reason === 'reload') return
|
|
475
|
+
if (event.reason === 'reload' || inSubagentChild()) return
|
|
470
476
|
const source = claudeSpelling(SESSION_START_SOURCE, event.reason)
|
|
471
477
|
const commands = matchingCommands(config.SessionStart, source.names)
|
|
472
478
|
const payload = { hook_event_name: 'SessionStart', source: source.value }
|
|
@@ -593,7 +599,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
593
599
|
pi.on('input', async (event, ctx) => {
|
|
594
600
|
// Only genuine user input; extension-injected messages (plan-mode, subagent) are not
|
|
595
601
|
// prompts the user submitted.
|
|
596
|
-
if (event.source === 'extension') return { action: 'continue' }
|
|
602
|
+
if (event.source === 'extension' || inSubagentChild()) return { action: 'continue' }
|
|
597
603
|
// The user typed, so the pending idle_prompt no longer applies.
|
|
598
604
|
cancelIdlePrompt()
|
|
599
605
|
// Genuine user input is progress, so it breaks a Stop-hook continuation streak: the
|
|
@@ -605,12 +611,15 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
605
611
|
ctx.ui.notify(decision.reason ?? 'Prompt blocked by hook', 'error')
|
|
606
612
|
return { action: 'handled' }
|
|
607
613
|
}
|
|
608
|
-
// Claude injects a UserPromptSubmit hook's context ahead of the prompt; transform is
|
|
609
|
-
// pi's seam for rewriting the submitted text. The prompt itself always survives:
|
|
610
614
|
// "UserPromptSubmit: can't replace the prompt; it only injects additionalContext
|
|
611
|
-
// alongside it"
|
|
615
|
+
// alongside it", as a system reminder with no visible transcript entry. So the context
|
|
616
|
+
// is a hidden message next to the prompt and the prompt text is left alone: prepended,
|
|
617
|
+
// it moved a `/skill:` or `/template` invocation off position 0 and pi stopped
|
|
618
|
+
// expanding it. pi appends a "nextTurn" message to this very prompt (after this handler
|
|
619
|
+
// runs); one typed mid-stream never reaches before_agent_start, so it is queued in the
|
|
620
|
+
// prompt's own mode. suppressOriginalPrompt scopes to the block message, which never
|
|
612
621
|
// carries the prompt here, so it needs nothing of its own.
|
|
613
|
-
if (decision.context)
|
|
622
|
+
if (decision.context) pi.sendMessage({ customType: 'claude-hook-context', content: decision.context, display: false }, { deliverAs: event.streamingBehavior ?? 'nextTurn' })
|
|
614
623
|
return { action: 'continue' }
|
|
615
624
|
})
|
|
616
625
|
|
|
@@ -630,6 +639,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
630
639
|
* turn cancels it. Observational only; exit codes and JSON output are ignored. */
|
|
631
640
|
const armIdlePrompt = (ctx: ExtensionContext): void => {
|
|
632
641
|
cancelIdlePrompt()
|
|
642
|
+
if (inSubagentChild()) return
|
|
633
643
|
const notifyCommands = matchingCommands(config.Notification, ['idle_prompt'])
|
|
634
644
|
if (notifyCommands.length > 0) {
|
|
635
645
|
const runner = boundRunner(ctx)
|
|
@@ -639,21 +649,26 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
639
649
|
idlePromptTimer.unref?.()
|
|
640
650
|
}
|
|
641
651
|
}
|
|
652
|
+
/** In a subagent child, the agent-frontmatter Stop hooks were converted to SubagentStop
|
|
653
|
+
* and fire at the child's own end, notify-style, before the Stop early-returns, which do
|
|
654
|
+
* not apply to them. Only the agent's own frontmatter hooks (stamped agent:<name>): the
|
|
655
|
+
* settings-level SubagentStop is fired by the parent when the child completes, so the
|
|
656
|
+
* child firing it too ran it twice. */
|
|
657
|
+
const fireChildSubagentStop = async (event: { messages?: Array<{ role: string; content: unknown }> }, ctx: ExtensionContext): Promise<void> => {
|
|
658
|
+
if (!agentIdentity) return
|
|
659
|
+
const subStop = matchingCommands(config.SubagentStop, agentIdentity.agent).filter((command) => command.origin?.startsWith('agent:'))
|
|
660
|
+
if (subStop.length === 0) return
|
|
661
|
+
const subText = lastAssistantText(event.messages ?? [])
|
|
662
|
+
const subPayload = { hook_event_name: 'SubagentStop', agent_type: agentIdentity.agent, ...(agentIdentity.id ? { agent_id: agentIdentity.id } : {}), stop_hook_active: false, ...(subText ? { last_assistant_message: subText } : {}) }
|
|
663
|
+
await runNotifyHooks(subStop, subPayload, boundRunner(ctx)).catch(() => {})
|
|
664
|
+
}
|
|
665
|
+
|
|
642
666
|
pi.on('agent_end', async (event, ctx) => {
|
|
643
667
|
armIdlePrompt(ctx)
|
|
668
|
+
await fireChildSubagentStop(event as { messages?: Array<{ role: string; content: unknown }> }, ctx)
|
|
644
669
|
|
|
645
|
-
//
|
|
646
|
-
|
|
647
|
-
// Stop early-returns, which do not apply to them.
|
|
648
|
-
if (agentIdentity) {
|
|
649
|
-
const subStop = matchingCommands(config.SubagentStop, agentIdentity.agent)
|
|
650
|
-
if (subStop.length > 0) {
|
|
651
|
-
const subText = lastAssistantText((event as { messages?: Array<{ role: string; content: unknown }> }).messages ?? [])
|
|
652
|
-
const subPayload = { hook_event_name: 'SubagentStop', agent_type: agentIdentity.agent, ...(agentIdentity.id ? { agent_id: agentIdentity.id } : {}), stop_hook_active: false, ...(subText ? { last_assistant_message: subText } : {}) }
|
|
653
|
-
await runNotifyHooks(subStop, subPayload, boundRunner(ctx)).catch(() => {})
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
|
|
670
|
+
// Stop is the main agent's event; the child's completion was handled above.
|
|
671
|
+
if (inSubagentChild()) return
|
|
657
672
|
// Stop has no matcher support (a stray matcher is ignored, as Claude documents)
|
|
658
673
|
// and an `if`-carrying hook never runs on a non-tool event.
|
|
659
674
|
const commands = allCommands(config.Stop).filter((command) => passesIfFilter(command, undefined))
|
|
@@ -771,13 +786,16 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
771
786
|
const reason = claudeSpelling(SESSION_END_REASON, event.reason)
|
|
772
787
|
// SessionEnd rides Claude's short shared budget (see sessionEndTimeoutMs) so a
|
|
773
788
|
// slow hook cannot stall session exit, /new or /resume.
|
|
774
|
-
const sessionEndCommands = matchingCommands(config.SessionEnd, reason.names).filter((command) => passesIfFilter(command, undefined))
|
|
789
|
+
const sessionEndCommands = inSubagentChild() ? [] : matchingCommands(config.SessionEnd, reason.names).filter((command) => passesIfFilter(command, undefined))
|
|
775
790
|
const runner = boundRunner(ctx)
|
|
776
791
|
const results = await Promise.all(sessionEndCommands.map((command) => runner(command, { hook_event_name: 'SessionEnd', reason: reason.value }, sessionEndTimeoutMs(command))))
|
|
777
792
|
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
778
|
-
// Claude
|
|
779
|
-
//
|
|
780
|
-
|
|
793
|
+
// Claude: "In non-interactive mode with the -p flag, Claude Code kills any async hook
|
|
794
|
+
// still running at teardown". Only there: an interactive session leaves them running, so
|
|
795
|
+
// an async SessionEnd hook, started by this very shutdown, can finish instead of being
|
|
796
|
+
// killed a microtask after it spawned. Their delivery targets a disposed context and is
|
|
797
|
+
// caught (see deliverBackgroundResult).
|
|
798
|
+
if (ctx.hasUI !== true) for (const kill of backgroundKills) kill()
|
|
781
799
|
backgroundKills.clear()
|
|
782
800
|
})
|
|
783
801
|
|
|
@@ -26,14 +26,15 @@ export interface TurnOverride<T> {
|
|
|
26
26
|
reset(): void
|
|
27
27
|
/** Restore the captured prior via `set` and clear the capture. A conditional override
|
|
28
28
|
* stands down when the current value has moved off the target. No-op when nothing is
|
|
29
|
-
* armed.
|
|
30
|
-
|
|
29
|
+
* armed. Returns what `set` returns, so a caller that must not exit before an async
|
|
30
|
+
* restore has landed (a shutdown) can await it. */
|
|
31
|
+
settle(): void | Promise<void>
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
export function createTurnOverride<T>(opts: {
|
|
34
35
|
/** Applies the restore value. Consumers wrap their setter here (async-with-catch for
|
|
35
36
|
* commands' model, plain for the thinking level). */
|
|
36
|
-
set: (value: T) => void
|
|
37
|
+
set: (value: T) => void | Promise<void>
|
|
37
38
|
/** Reads the current value, for a conditional restore. Only consulted when
|
|
38
39
|
* `conditional` is set. */
|
|
39
40
|
get?: () => T | undefined
|
|
@@ -67,7 +68,7 @@ export function createTurnOverride<T>(opts: {
|
|
|
67
68
|
const current = opts.get?.()
|
|
68
69
|
if (current !== undefined && current !== movedTo) return
|
|
69
70
|
}
|
|
70
|
-
opts.set(restore)
|
|
71
|
+
return opts.set(restore)
|
|
71
72
|
},
|
|
72
73
|
}
|
|
73
74
|
}
|
|
@@ -64,18 +64,28 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|
|
64
64
|
let todoItems: TodoItem[] = []
|
|
65
65
|
let planFromTool = false
|
|
66
66
|
let savedTools: string[] = []
|
|
67
|
+
/** Every tool pi had registered when the set was saved, active or not: a tool outside it
|
|
68
|
+
* was registered later, and pi activated it on top of whatever set was in force. */
|
|
69
|
+
let toolsAtSave = new Set<string>()
|
|
67
70
|
let stalledRuns = 0
|
|
68
71
|
let runProgress = false
|
|
69
72
|
|
|
73
|
+
const registeredTools = (): Set<string> => new Set((pi.getAllTools?.() ?? []).map((tool) => tool.name))
|
|
74
|
+
|
|
70
75
|
function enterPlanTools(): void {
|
|
71
76
|
savedTools = pi.getActiveTools()
|
|
77
|
+
toolsAtSave = registeredTools()
|
|
72
78
|
pi.setActiveTools(PLAN_MODE_TOOLS.filter((t) => savedTools.includes(t)))
|
|
73
79
|
}
|
|
74
80
|
|
|
81
|
+
/** Put back the saved set, plus any tool registered since: pi activates a late one (an MCP
|
|
82
|
+
* server past its connect window) on top of the restricted set, and overwriting with the
|
|
83
|
+
* snapshot dropped it for the rest of the session. A tool that was registered but not
|
|
84
|
+
* active at entry is not late, and stays out. */
|
|
75
85
|
function restoreTools(): void {
|
|
76
|
-
if (savedTools.length
|
|
77
|
-
|
|
78
|
-
|
|
86
|
+
if (savedTools.length === 0) return
|
|
87
|
+
const late = pi.getActiveTools().filter((name) => !toolsAtSave.has(name) && !savedTools.includes(name))
|
|
88
|
+
pi.setActiveTools([...savedTools, ...late])
|
|
79
89
|
}
|
|
80
90
|
|
|
81
91
|
/** Hooks report Claude's permission_mode from this bus state. */
|
|
@@ -457,6 +467,7 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
457
467
|
// snapshot when plan mode is off would instead push a stale set over whatever
|
|
458
468
|
// pi has registered since, so it stays scoped to this branch.
|
|
459
469
|
savedTools = restored.savedTools ?? pi.getActiveTools()
|
|
470
|
+
toolsAtSave = registeredTools()
|
|
460
471
|
pi.setActiveTools(PLAN_MODE_TOOLS.filter((t) => savedTools.includes(t)))
|
|
461
472
|
// --plan enters plan mode without ever toggling, so nothing has persisted yet
|
|
462
473
|
// and a /reload would find no snapshot to restore from. Record it now, while
|
package/extensions/thinking.ts
CHANGED
|
@@ -91,11 +91,17 @@ export default function thinkingExtension(pi: ExtensionAPI) {
|
|
|
91
91
|
// the keyword in the prompt.
|
|
92
92
|
})
|
|
93
93
|
|
|
94
|
+
// agent_settled never fires when pi is quit mid-run, and the escalation is already in the
|
|
95
|
+
// transcript: `pi -c` would resume at the raised level for good.
|
|
96
|
+
pi.on('session_shutdown', async () => {
|
|
97
|
+
await override.settle()
|
|
98
|
+
})
|
|
99
|
+
|
|
94
100
|
pi.on('agent_settled', () => {
|
|
95
101
|
// Restore the pre-escalation level, but only if nothing else moved it since (a
|
|
96
102
|
// command's effort restore, a manual change): the conditional override stands down in
|
|
97
103
|
// that case and restores unconditionally when the level cannot be read, the prior
|
|
98
104
|
// best-effort behavior.
|
|
99
|
-
override.settle()
|
|
105
|
+
void override.settle()
|
|
100
106
|
})
|
|
101
107
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.72",
|
|
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, subagents, and goals",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|