@alexlikevibe/pi-jev 0.2.0

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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +340 -0
  3. package/README.zh-CN.md +340 -0
  4. package/bin/pi-jev.js +13 -0
  5. package/dist/cli/main.js +223 -0
  6. package/dist/commands/completions.js +87 -0
  7. package/dist/commands/extension.js +58 -0
  8. package/dist/commands/menu.js +245 -0
  9. package/dist/commands/models.js +23 -0
  10. package/dist/compaction/convert.js +87 -0
  11. package/dist/compaction/decision.js +195 -0
  12. package/dist/compaction/extension.js +150 -0
  13. package/dist/compaction/jev.js +72 -0
  14. package/dist/compaction/summarize.js +68 -0
  15. package/dist/routing/decide.js +57 -0
  16. package/dist/routing/extension.js +81 -0
  17. package/dist/shared/config.js +157 -0
  18. package/dist/vendor/fast-jev-compaction/client.js +25 -0
  19. package/dist/vendor/fast-jev-compaction/compact.js +233 -0
  20. package/dist/vendor/fast-jev-compaction/index.js +7 -0
  21. package/dist/vendor/fast-jev-compaction/request.js +50 -0
  22. package/dist/vendor/fast-jev-compaction/state.js +255 -0
  23. package/dist/vendor/fast-jev-compaction/types.js +1 -0
  24. package/extensions/compaction.ts +1 -0
  25. package/extensions/jev.ts +1 -0
  26. package/extensions/routing.ts +1 -0
  27. package/media/banner.svg +198 -0
  28. package/package.json +55 -0
  29. package/src/cli/main.ts +241 -0
  30. package/src/commands/completions.ts +107 -0
  31. package/src/commands/extension.ts +61 -0
  32. package/src/commands/menu.ts +291 -0
  33. package/src/commands/models.ts +43 -0
  34. package/src/compaction/convert.ts +95 -0
  35. package/src/compaction/decision.ts +262 -0
  36. package/src/compaction/extension.ts +235 -0
  37. package/src/compaction/jev.ts +133 -0
  38. package/src/compaction/summarize.ts +80 -0
  39. package/src/routing/decide.ts +81 -0
  40. package/src/routing/extension.ts +92 -0
  41. package/src/shared/config.ts +280 -0
  42. package/src/vendor/fast-jev-compaction/LICENSE +21 -0
  43. package/src/vendor/fast-jev-compaction/client.ts +43 -0
  44. package/src/vendor/fast-jev-compaction/compact.ts +309 -0
  45. package/src/vendor/fast-jev-compaction/index.ts +7 -0
  46. package/src/vendor/fast-jev-compaction/request.ts +80 -0
  47. package/src/vendor/fast-jev-compaction/state.ts +304 -0
  48. package/src/vendor/fast-jev-compaction/types.ts +202 -0
@@ -0,0 +1,198 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1200" height="440" viewBox="0 0 1200 440" role="img" aria-labelledby="banner-title banner-desc">
2
+ <title id="banner-title">pi-jev: typed judgments become actions</title>
3
+ <desc id="banner-desc">Two examples of the same pipeline, triggered independently by pi events. For compaction, Jev returns five keep scores; a 0.5 threshold selects the first and last tool calls, preserving their text verbatim. For routing, a difficulty level of 0.3 selects the cheap model from three branches. Each example illustrates a typed Jev request; large compactions may be batched. The animation plays once and ends in a complete, readable diagram.</desc>
4
+ <defs>
5
+ <pattern id="grid" width="32" height="32" patternUnits="userSpaceOnUse">
6
+ <path d="M32 0H0V32" fill="none" stroke="#293341" stroke-width="0.7"/>
7
+ </pattern>
8
+ <linearGradient id="engine" x1="0" y1="0" x2="1" y2="1">
9
+ <stop stop-color="#142033"/><stop offset="1" stop-color="#111820"/>
10
+ </linearGradient>
11
+ <linearGradient id="engine-edge" x1="0" x2="1">
12
+ <stop stop-color="#58a6ff" stop-opacity="0.05"/><stop offset="0.5" stop-color="#58a6ff" stop-opacity="0.8"/><stop offset="1" stop-color="#58a6ff" stop-opacity="0.05"/>
13
+ </linearGradient>
14
+ <filter id="glow" x="-100%" y="-100%" width="300%" height="300%">
15
+ <feGaussianBlur stdDeviation="2.5" result="blur"/>
16
+ <feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
17
+ </filter>
18
+ <clipPath id="gauge-clip"><rect x="416" y="319" width="304" height="14" rx="7"/></clipPath>
19
+ <style>
20
+ text { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
21
+ .heading { fill:#a5b3c4; font-size:14px; letter-spacing:1.6px; }
22
+ .step { fill:#64758b; font-size:12px; }
23
+ .label { fill:#a5b3c4; font-size:14px; }
24
+ .question { fill:#d5e4f5; font-size:14px; }
25
+ .number { fill:#79b8ff; font-size:13px; }
26
+ .reveal { animation:reveal .5s ease-out both; animation-delay:var(--at,0s); }
27
+ .bar { transform-box:fill-box; transform-origin:center bottom; animation:grow .55s cubic-bezier(.2,.7,.2,1) both; animation-delay:var(--at,1.4s); }
28
+ .keep { fill:#47c975; animation:keep-color .35s ease both; animation-delay:2.25s; }
29
+ .drop { fill:#506074; animation:drop-color .35s ease both; animation-delay:2.25s; }
30
+ .pointer { animation:pointer .55s cubic-bezier(.2,.7,.2,1) both; animation-delay:3.1s; }
31
+ .chosen { stroke-dasharray:170; animation:choose .65s ease-out both; animation-delay:3.8s; }
32
+ .packet { opacity:0; animation:packet .6s linear both; animation-delay:var(--at,1s); }
33
+ @keyframes reveal { from { opacity:0; } to { opacity:1; } }
34
+ @keyframes grow { from { transform:scaleY(0); } to { transform:scaleY(1); } }
35
+ @keyframes keep-color { from { fill:#79a6d5; } to { fill:#47c975; } }
36
+ @keyframes drop-color { from { fill:#79a6d5; } to { fill:#506074; } }
37
+ @keyframes pointer { from { transform:translateX(140px); opacity:0; } to { transform:translateX(0); opacity:1; } }
38
+ @keyframes choose { from { stroke-dashoffset:170; opacity:0; } to { stroke-dashoffset:0; opacity:1; } }
39
+ @keyframes packet { 0% { transform:translateX(0); opacity:0; } 20% { opacity:1; } 80% { opacity:1; } 100% { transform:translateX(48px); opacity:0; } }
40
+ @media (prefers-reduced-motion:reduce) {
41
+ .reveal,.bar,.keep,.drop,.pointer,.chosen { animation:none; }
42
+ .packet { display:none; }
43
+ }
44
+ </style>
45
+ </defs>
46
+
47
+ <rect width="1200" height="440" rx="16" fill="#0d1117"/>
48
+ <rect x=".5" y=".5" width="1199" height="439" rx="15.5" fill="url(#grid)" fill-opacity=".26" stroke="#28313c" stroke-opacity=".65"/>
49
+
50
+ <!-- A shared three-stage structure, with one illustrated event in each row. -->
51
+ <g class="reveal" style="--at:.08s">
52
+ <text class="step" x="44" y="46">01</text><text class="heading" x="74" y="46">PI EVENT</text>
53
+ <text class="step" x="382" y="46" fill="#79b8ff">02</text><text class="heading" x="412" y="46" style="fill:#bdd9fa">TYPED JEV REQUEST</text>
54
+ <text class="step" x="840" y="46">03</text><text class="heading" x="870" y="46">ACTION</text>
55
+ </g>
56
+ <rect x="44" y="72" width="288" height="324" rx="14" fill="#131920" stroke="#2c3540"/>
57
+ <rect x="382" y="72" width="408" height="324" rx="14" fill="url(#engine)" stroke="#395779"/>
58
+ <path d="M398 72H774" stroke="url(#engine-edge)" stroke-width="2"/>
59
+ <rect x="840" y="72" width="316" height="324" rx="14" fill="#131920" stroke="#2c3540"/>
60
+ <path d="M64 236H312M402 236H770M860 236H1136" stroke="#2c3540"/>
61
+ <g fill="none" stroke="#566578" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
62
+ <path d="M340 171H373M369 168L373 171L369 174M798 171H831M827 168L831 171L827 174M340 326H373M369 323L373 326L369 329M798 326H831M827 323L831 326L827 329"/>
63
+ </g>
64
+ <g fill="#79b8ff">
65
+ <circle class="packet" style="--at:1s" cx="338" cy="171" r="2.5"/>
66
+ <circle class="packet" style="--at:2.6s" cx="338" cy="326" r="2.5"/>
67
+ <circle class="packet" style="--at:2.65s" cx="794" cy="171" r="2.5"/>
68
+ <circle class="packet" style="--at:3.55s" cx="794" cy="326" r="2.5"/>
69
+ </g>
70
+
71
+ <!-- Compaction: the input and output share the same five row shapes. -->
72
+ <text class="label reveal" style="--at:.2s" x="66" y="105">compaction</text>
73
+
74
+ <g class="reveal" style="--at:0.35s">
75
+ <rect x="66" y="129" width="10" height="10" rx="2" fill="#1f2b39" stroke="#5c7189"/>
76
+ <path d="M69 134H73" stroke="#8395aa" stroke-width="1.3"/>
77
+ <rect x="88" y="131" width="146" height="6" rx="3" fill="#64758a"/>
78
+ </g>
79
+ <g class="reveal" style="--at:0.45s">
80
+ <rect x="66" y="147" width="10" height="10" rx="2" fill="#1f2b39" stroke="#5c7189"/>
81
+ <path d="M69 152H73" stroke="#8395aa" stroke-width="1.3"/>
82
+ <rect x="88" y="149" width="198" height="6" rx="3" fill="#64758a"/>
83
+ </g>
84
+ <g class="reveal" style="--at:0.55s">
85
+ <rect x="66" y="165" width="10" height="10" rx="2" fill="#1f2b39" stroke="#5c7189"/>
86
+ <path d="M69 170H73" stroke="#8395aa" stroke-width="1.3"/>
87
+ <rect x="88" y="167" width="126" height="6" rx="3" fill="#64758a"/>
88
+ </g>
89
+ <g class="reveal" style="--at:0.65s">
90
+ <rect x="66" y="183" width="10" height="10" rx="2" fill="#1f2b39" stroke="#5c7189"/>
91
+ <path d="M69 188H73" stroke="#8395aa" stroke-width="1.3"/>
92
+ <rect x="88" y="185" width="174" height="6" rx="3" fill="#64758a"/>
93
+ </g>
94
+ <g class="reveal" style="--at:0.75s">
95
+ <rect x="66" y="201" width="10" height="10" rx="2" fill="#1f2b39" stroke="#5c7189"/>
96
+ <path d="M69 206H73" stroke="#8395aa" stroke-width="1.3"/>
97
+ <rect x="88" y="203" width="158" height="6" rx="3" fill="#64758a"/>
98
+ </g>
99
+
100
+ <g class="reveal" style="--at:1.15s">
101
+ <rect x="412" y="91" width="84" height="26" rx="7" fill="#0e1621" stroke="#334963"/>
102
+ <text class="question" x="427" y="109">keep?</text>
103
+ </g>
104
+ <path class="reveal" style="--at:1.35s" d="M416 210H710" stroke="#3b4b60"/>
105
+
106
+ <g class="bar" style="--at:1.40s"><rect class="keep" x="432" y="152" width="32" height="58" rx="4"/></g>
107
+ <g class="bar" style="--at:1.49s"><rect class="drop" x="490" y="201" width="32" height="9" rx="4"/></g>
108
+ <g class="bar" style="--at:1.58s"><rect class="drop" x="548" y="200" width="32" height="10" rx="4"/></g>
109
+ <g class="bar" style="--at:1.67s"><rect class="drop" x="606" y="186" width="32" height="24" rx="4"/></g>
110
+ <g class="bar" style="--at:1.76s"><rect class="keep" x="664" y="150" width="32" height="60" rx="4"/></g>
111
+
112
+ <g class="reveal" style="--at:2.25s">
113
+ <path d="M416 172.5H710" fill="none" stroke="#79b8ff" stroke-width="1.5" stroke-dasharray="4 5"/>
114
+ <text class="number" x="723" y="177">0.5</text>
115
+ </g>
116
+ <text class="label reveal" style="--at:2.9s" x="862" y="105">verbatim</text>
117
+
118
+ <g class="reveal" style="--at:3.00s">
119
+ <g opacity="1">
120
+ <rect x="862" y="129" width="10" height="10" rx="2" fill="#47c975" stroke="#47c975"/>
121
+ <rect x="884" y="131" width="146" height="6" rx="3" fill="#85d9a3"/>
122
+
123
+ <path d="M864 134L866 136L870 132" fill="none" stroke="#0d3420" stroke-width="1.1"/>
124
+ </g>
125
+ </g>
126
+ <g class="reveal" style="--at:3.08s">
127
+ <g opacity="0.35">
128
+ <rect x="862" y="147" width="10" height="10" rx="2" fill="#19212b" stroke="#5b6777"/>
129
+ <rect x="884" y="149" width="198" height="6" rx="3" fill="#596677"/>
130
+
131
+ <path d="M864 155L870 149M881 152H1085" stroke="#7d8b9e" stroke-width="1.2"/>
132
+ </g>
133
+ </g>
134
+ <g class="reveal" style="--at:3.16s">
135
+ <g opacity="0.35">
136
+ <rect x="862" y="165" width="10" height="10" rx="2" fill="#19212b" stroke="#5b6777"/>
137
+ <rect x="884" y="167" width="126" height="6" rx="3" fill="#596677"/>
138
+
139
+ <path d="M864 173L870 167M881 170H1013" stroke="#7d8b9e" stroke-width="1.2"/>
140
+ </g>
141
+ </g>
142
+ <g class="reveal" style="--at:3.24s">
143
+ <g opacity="0.35">
144
+ <rect x="862" y="183" width="10" height="10" rx="2" fill="#19212b" stroke="#5b6777"/>
145
+ <rect x="884" y="185" width="174" height="6" rx="3" fill="#596677"/>
146
+
147
+ <path d="M864 191L870 185M881 188H1061" stroke="#7d8b9e" stroke-width="1.2"/>
148
+ </g>
149
+ </g>
150
+ <g class="reveal" style="--at:3.32s">
151
+ <g opacity="1">
152
+ <rect x="862" y="201" width="10" height="10" rx="2" fill="#47c975" stroke="#47c975"/>
153
+ <rect x="884" y="203" width="158" height="6" rx="3" fill="#85d9a3"/>
154
+
155
+ <path d="M864 206L866 208L870 204" fill="none" stroke="#0d3420" stroke-width="1.1"/>
156
+ </g>
157
+ </g>
158
+
159
+ <!-- Routing: a contiguous numeric scale drives a single lit branch. -->
160
+ <text class="label reveal" style="--at:.85s" x="66" y="269">routing</text>
161
+ <g class="reveal" style="--at:1s">
162
+ <path d="M78 298H286Q298 298 298 310V343Q298 355 286 355H91L78 367V355Q66 355 66 343V310Q66 298 78 298Z" fill="#0e141c" stroke="#3a4a5d"/>
163
+ <circle cx="86" cy="317" r="3" fill="#79b8ff"/>
164
+ <rect x="100" y="314" width="162" height="6" rx="3" fill="#64758a"/>
165
+ <rect x="84" y="334" width="114" height="5" rx="2.5" fill="#3e4e62"/>
166
+ </g>
167
+ <g class="reveal" style="--at:2.65s">
168
+ <rect x="412" y="255" width="132" height="26" rx="7" fill="#0e1621" stroke="#334963"/>
169
+ <text class="question" x="427" y="273">difficulty?</text>
170
+ </g>
171
+ <g class="reveal" style="--at:2.85s">
172
+ <g clip-path="url(#gauge-clip)">
173
+ <rect x="416" y="319" width="76" height="14" fill="#386ca6"/>
174
+ <rect x="492" y="319" width="152" height="14" fill="#354355"/>
175
+ <rect x="644" y="319" width="76" height="14" fill="#2b7550"/>
176
+ </g>
177
+ <path d="M492 311V342M644 311V342" fill="none" stroke="#8aa0ba" stroke-dasharray="3 4" stroke-opacity=".6"/>
178
+ </g>
179
+ <g class="pointer">
180
+ <path d="M462 313V339" stroke="#dcecff" stroke-width="3" stroke-linecap="round"/>
181
+ <circle cx="462" cy="326" r="3.5" fill="#dcecff" filter="url(#glow)"/>
182
+ <text class="number" x="462" y="364" text-anchor="middle">0.3</text>
183
+ </g>
184
+ <g class="reveal" style="--at:3.55s">
185
+ <path d="M862 326H908Q920 326 920 314V296Q920 284 932 284H984M908 326H984M908 326Q920 326 920 338V356Q920 368 932 368H984" fill="none" stroke="#465569" stroke-width="1.5"/>
186
+ <circle cx="992" cy="284" r="5" fill="#131920" stroke="#5c7189" stroke-width="1.5"/>
187
+ <circle cx="992" cy="326" r="5" fill="#131920" stroke="#5c7189" stroke-width="1.5"/>
188
+ <circle cx="992" cy="368" r="5" fill="#131920" stroke="#5c7189" stroke-width="1.5"/>
189
+ <text x="1009" y="289" font-size="15" fill="#78b7ff">cheap</text>
190
+ <text x="1009" y="331" font-size="15" fill="#7f90a5">current</text>
191
+ <text x="1009" y="373" font-size="15" fill="#7f90a5">strong</text>
192
+ </g>
193
+ <path class="chosen" d="M862 326H908Q920 326 920 314V296Q920 284 932 284H986" fill="none" stroke="#79b8ff" stroke-width="2.5" stroke-linecap="round"/>
194
+ <g class="reveal" style="--at:4.25s">
195
+ <circle cx="992" cy="284" r="5" fill="#79b8ff" filter="url(#glow)"/>
196
+ <text x="1009" y="289" font-size="15" fill="#bdd9fa">cheap</text>
197
+ </g>
198
+ </svg>
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@alexlikevibe/pi-jev",
3
+ "version": "0.2.0",
4
+ "description": "Pi extension suite powered by Jev: selective context compaction and model routing, each an independently toggleable extension.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "publishConfig": {
8
+ "access": "public",
9
+ "registry": "https://registry.npmjs.org/"
10
+ },
11
+ "scripts": {
12
+ "build": "tsc -p tsconfig.build.json",
13
+ "prepare": "npm run build",
14
+ "typecheck": "tsc --noEmit",
15
+ "test": "vitest run"
16
+ },
17
+ "bin": {
18
+ "pi-jev": "bin/pi-jev.js"
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "bin",
23
+ "src",
24
+ "extensions",
25
+ "README.md",
26
+ "README.zh-CN.md",
27
+ "media",
28
+ "LICENSE"
29
+ ],
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/iefnaf/pi-jev.git"
33
+ },
34
+ "keywords": [
35
+ "pi",
36
+ "pi-extension",
37
+ "pi-package",
38
+ "jev",
39
+ "typesafe",
40
+ "compaction",
41
+ "model-routing",
42
+ "openrouter"
43
+ ],
44
+ "dependencies": {
45
+ "@narumitw/pi-tui-kit": "^0.65.0"
46
+ },
47
+ "devDependencies": {
48
+ "@earendil-works/pi-agent-core": "^0.85.1",
49
+ "@earendil-works/pi-ai": "^0.85.1",
50
+ "@earendil-works/pi-coding-agent": "^0.85.1",
51
+ "@types/node": "^22.10.2",
52
+ "typescript": "^5.7.2",
53
+ "vitest": "^2.1.8"
54
+ }
55
+ }
@@ -0,0 +1,241 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import {
4
+ CONFIG_KEYS,
5
+ configFromEnv,
6
+ loadConfig,
7
+ mergeConfigFiles,
8
+ readConfigFile,
9
+ type ConfigKeyMeta,
10
+ type JevFileConfig,
11
+ } from '../shared/config.js';
12
+
13
+ /** Injectable process surface, so tests can run the CLI against temp dirs. */
14
+ export interface CliIo {
15
+ env: Record<string, string | undefined>;
16
+ globalPath: string;
17
+ projectPath: string;
18
+ out(text: string): void;
19
+ err(text: string): void;
20
+ }
21
+
22
+ const USAGE = `pi-jev — configuration CLI for the pi-jev extension suite
23
+
24
+ Usage:
25
+ pi-jev config Show resolved config and each value's source
26
+ pi-jev config set <key> <value> Write a key (global by default)
27
+ pi-jev config get <key> Print the resolved value of a key
28
+ pi-jev config unset <key> Remove a key
29
+ pi-jev config path Print the config file path
30
+ pi-jev config keys List all keys with types and env overrides
31
+
32
+ Options:
33
+ -l, --project Target the project file (.pi/jev.json) instead of the global one
34
+
35
+ Sources resolve highest-first: environment variables > project file > global file > defaults.
36
+ API keys are environment-only and never stored in files.
37
+ Changes take effect after pi restarts or /reload.`;
38
+
39
+ export function keyMeta(path: string): ConfigKeyMeta | undefined {
40
+ return CONFIG_KEYS.find((key) => key.path === path);
41
+ }
42
+
43
+ export function getPath(file: JevFileConfig, path: string): unknown {
44
+ const [section, key] = path.split('.');
45
+ if (!key) return (file as Record<string, unknown>)[section];
46
+ return (file as Record<string, Record<string, unknown>>)[section]?.[key];
47
+ }
48
+
49
+ export function setPath(file: JevFileConfig, path: string, value: unknown): void {
50
+ const [section, key] = path.split('.');
51
+ if (!key) {
52
+ (file as Record<string, unknown>)[section] = value;
53
+ return;
54
+ }
55
+ const holder = ((file as Record<string, Record<string, unknown>>)[section] ??= {});
56
+ holder[key] = value;
57
+ }
58
+
59
+ function deletePath(file: JevFileConfig, path: string): boolean {
60
+ const [section, key] = path.split('.');
61
+ if (!key) return delete (file as Record<string, unknown>)[section];
62
+ const holder = (file as Record<string, Record<string, unknown>>)[section];
63
+ if (!holder || !(key in holder)) return false;
64
+ delete holder[key];
65
+ if (Object.keys(holder).length === 0) delete (file as Record<string, unknown>)[section];
66
+ return true;
67
+ }
68
+
69
+ export function coerce(meta: ConfigKeyMeta, raw: string): string | number | boolean {
70
+ if (meta.type === 'number') {
71
+ const value = Number(raw);
72
+ if (!Number.isFinite(value)) throw new Error(`${meta.path}: expected a number, got "${raw}"`);
73
+ return value;
74
+ }
75
+ if (meta.type === 'boolean') {
76
+ if (/^(1|true|yes)$/i.test(raw)) return true;
77
+ if (/^(0|false|no)$/i.test(raw)) return false;
78
+ throw new Error(`${meta.path}: expected a boolean, got "${raw}"`);
79
+ }
80
+ const value = raw.trim();
81
+ if (!value) throw new Error(`${meta.path}: expected a non-empty value`);
82
+ if (meta.path === 'provider' && value !== 'typesafe' && value !== 'openrouter') {
83
+ throw new Error(`provider: expected "typesafe" or "openrouter", got "${value}"`);
84
+ }
85
+ return value;
86
+ }
87
+
88
+ export function readOrInit(path: string): JevFileConfig {
89
+ try {
90
+ return readConfigFile(path);
91
+ } catch (error) {
92
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {};
93
+ throw new Error(`cannot read ${path}: ${(error as Error).message}`);
94
+ }
95
+ }
96
+
97
+ export function saveFile(path: string, file: JevFileConfig): void {
98
+ mkdirSync(dirname(path), { recursive: true });
99
+ writeFileSync(path, JSON.stringify(file, null, 2) + '\n');
100
+ }
101
+
102
+ function takeProjectFlag(rest: string[]): { project: boolean; positional: string[] } {
103
+ const positional = rest.filter((arg) => arg !== '-l' && arg !== '--project');
104
+ return { project: positional.length !== rest.length, positional };
105
+ }
106
+
107
+ function tryReadFile(path: string, io: CliIo): JevFileConfig | undefined {
108
+ try {
109
+ return readConfigFile(path);
110
+ } catch (error) {
111
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
112
+ io.err(`pi-jev: ignoring config at ${path} (${(error as Error).message})`);
113
+ return undefined;
114
+ }
115
+ }
116
+
117
+ export function valueAt(config: unknown, path: string): unknown {
118
+ const record = config as Record<string, unknown>;
119
+ // `compaction.*` keys are flat fields on JevConfig; `routing.*` is nested.
120
+ if (path.startsWith('compaction.')) return record[path.slice('compaction.'.length)];
121
+ const [section, key] = path.split('.');
122
+ if (!key) return record[section];
123
+ return (record[section] as Record<string, unknown> | undefined)?.[key];
124
+ }
125
+
126
+ export function formatValue(value: unknown): string {
127
+ if (value === undefined) return 'unset';
128
+ return String(value);
129
+ }
130
+
131
+ function show(io: CliIo): number {
132
+ const global = tryReadFile(io.globalPath, io);
133
+ const project = tryReadFile(io.projectPath, io);
134
+ const config = configFromEnv(io.env, mergeConfigFiles(global, project));
135
+ const width = Math.max(...CONFIG_KEYS.map((key) => key.path.length));
136
+ for (const meta of CONFIG_KEYS) {
137
+ const source = io.env[meta.env ?? '']?.trim()
138
+ ? 'env'
139
+ : getPath(project ?? {}, meta.path) !== undefined
140
+ ? 'project'
141
+ : getPath(global ?? {}, meta.path) !== undefined
142
+ ? 'global'
143
+ : 'default';
144
+ io.out(`${meta.path.padEnd(width)} = ${formatValue(valueAt(config, meta.path)).padEnd(28)} # ${source}`);
145
+ }
146
+ io.out(`${'apiKey'.padEnd(width)} = ${config.apiKey ? 'set' : 'unset'}${''.padEnd(23)} # env only`);
147
+ if (!config.routing.cheap && !config.routing.strong) {
148
+ io.err('# routing is disabled: set routing.cheap (or routing.strong) to enable it');
149
+ }
150
+ return 0;
151
+ }
152
+
153
+ /** Runs the CLI against injectable IO. Returns the process exit code. */
154
+ export function runCli(argv: readonly string[], io: CliIo): number {
155
+ const [cmd, sub, ...rest] = argv;
156
+ if (cmd === undefined || cmd === 'help' || cmd === '--help' || cmd === '-h') {
157
+ io.out(USAGE);
158
+ return 0;
159
+ }
160
+ if (cmd !== 'config') {
161
+ io.err(`unknown command "${cmd}"`);
162
+ io.err(USAGE);
163
+ return 1;
164
+ }
165
+ try {
166
+ if (sub === undefined) return show(io);
167
+
168
+ // Actions: `pi-jev config set|get|unset <key> [value] [-l]`
169
+ if (sub === 'set') {
170
+ const { project, positional } = takeProjectFlag(rest);
171
+ const [keyPath, ...valueParts] = positional;
172
+ const meta = keyMeta(keyPath ?? '');
173
+ if (!meta) {
174
+ io.err(`unknown key "${keyPath}" (API keys are env-only). Run 'pi-jev config keys' to list valid keys.`);
175
+ return 1;
176
+ }
177
+ const raw = valueParts.join(' ');
178
+ if (!raw.trim()) {
179
+ io.err(`usage: pi-jev config set ${meta.path} <value>`);
180
+ return 1;
181
+ }
182
+ const value = coerce(meta, raw);
183
+ const targetPath = project ? io.projectPath : io.globalPath;
184
+ const file = readOrInit(targetPath);
185
+ setPath(file, meta.path, value);
186
+ saveFile(targetPath, file);
187
+ io.out(`set ${meta.path} = ${String(value)} (${project ? 'project' : 'global'}: ${targetPath})`);
188
+ return 0;
189
+ }
190
+
191
+ if (sub === 'get' || sub === 'unset') {
192
+ const { project, positional } = takeProjectFlag(rest);
193
+ const meta = keyMeta(positional[0] ?? '');
194
+ if (!meta) {
195
+ io.err(`unknown key "${positional[0]}" (API keys are env-only). Run 'pi-jev config keys' to list valid keys.`);
196
+ return 1;
197
+ }
198
+ const targetPath = project ? io.projectPath : io.globalPath;
199
+ if (sub === 'get') {
200
+ const config = loadConfig({ env: io.env, globalPath: io.globalPath, projectPath: io.projectPath, warn: io.err });
201
+ io.out(formatValue(valueAt(config, meta.path)));
202
+ return 0;
203
+ }
204
+ const file = readOrInit(targetPath);
205
+ if (deletePath(file, meta.path)) {
206
+ saveFile(targetPath, file);
207
+ io.out(`unset ${meta.path} (${project ? 'project' : 'global'}: ${targetPath})`);
208
+ } else {
209
+ io.out(`${meta.path} is not set (${project ? 'project' : 'global'})`);
210
+ }
211
+ return 0;
212
+ }
213
+
214
+ if (sub === 'path') {
215
+ const { project } = takeProjectFlag(rest);
216
+ io.out(project ? io.projectPath : io.globalPath);
217
+ return 0;
218
+ }
219
+
220
+ if (sub === 'keys') {
221
+ const width = Math.max(...CONFIG_KEYS.map((key) => key.path.length));
222
+ for (const meta of CONFIG_KEYS) {
223
+ io.out(`${meta.path.padEnd(width)} ${meta.type.padEnd(7)} ${meta.env ? `env: ${meta.env.padEnd(26)} ` : ''}${meta.description}`);
224
+ }
225
+ return 0;
226
+ }
227
+
228
+ // Shorthand: `pi-jev config <key>` = get
229
+ const meta = keyMeta(sub);
230
+ if (!meta) {
231
+ io.err(`unknown key "${sub}" (API keys are env-only). Run 'pi-jev config keys' to list valid keys.`);
232
+ return 1;
233
+ }
234
+ const config = loadConfig({ env: io.env, globalPath: io.globalPath, projectPath: io.projectPath, warn: io.err });
235
+ io.out(formatValue(valueAt(config, meta.path)));
236
+ return 0;
237
+ } catch (error) {
238
+ io.err(`pi-jev: ${(error as Error).message}`);
239
+ return 1;
240
+ }
241
+ }
@@ -0,0 +1,107 @@
1
+ import { CONFIG_KEYS } from '../shared/config.js';
2
+ import type { ModelOption } from './models.js';
3
+
4
+ /**
5
+ * Completion items as pi's autocomplete expects them: `value` replaces the
6
+ * entire argument text after `/jev `, so multi-word values carry the leading
7
+ * tokens (e.g. `"set routing.easyMax"`).
8
+ */
9
+ export interface CompletionItem {
10
+ value: string;
11
+ label: string;
12
+ description?: string;
13
+ }
14
+
15
+ const ACTIONS: CompletionItem[] = [
16
+ { value: 'set', label: 'set', description: 'write a key (add -l for the project file)' },
17
+ { value: 'get', label: 'get', description: 'print the resolved value of a key' },
18
+ { value: 'unset', label: 'unset', description: 'remove a key' },
19
+ { value: 'keys', label: 'keys', description: 'list all keys with types and env overrides' },
20
+ { value: 'path', label: 'path', description: 'print the config file path' },
21
+ ];
22
+
23
+ const FLAG_RE = /^(-l|--project)$/;
24
+
25
+ function filterItems(items: CompletionItem[], prefix: string): CompletionItem[] | null {
26
+ const normalized = prefix.trim().toLowerCase();
27
+ const matches = items.filter((item) => item.value.toLowerCase().startsWith(normalized));
28
+ return matches.length > 0 ? matches : null;
29
+ }
30
+
31
+ function keyItems(action: string): CompletionItem[] {
32
+ return CONFIG_KEYS.map((meta) => ({
33
+ value: `${action} ${meta.path}`,
34
+ label: meta.path,
35
+ description: meta.description,
36
+ }));
37
+ }
38
+
39
+ /** Enum-valued keys offer their options; free-form keys get no value completion. */
40
+ export const VALUE_OPTIONS: Record<string, { label: string; description: string }[]> = {
41
+ provider: [
42
+ { label: 'openrouter', description: 'OpenRouter Decisions API' },
43
+ { label: 'typesafe', description: 'TypeSafe System One' },
44
+ ],
45
+ disabled: [
46
+ { label: 'true', description: 'bypass all pi-jev hooks' },
47
+ { label: 'false', description: 'enable pi-jev hooks' },
48
+ ],
49
+ };
50
+
51
+ /**
52
+ * Completes the argument text after `/jev `. Flags are position-neutral; the
53
+ * last non-flag token in progress decides what is offered.
54
+ */
55
+ /** Model refs for cheap/strong value completion; empty when no snapshot is available. */
56
+ const modelOptions: ModelOption[] = [];
57
+
58
+ /** Refreshes the model snapshot (called on session_start). */
59
+ export function setModelOptions(options: readonly ModelOption[]): void {
60
+ modelOptions.length = 0;
61
+ modelOptions.push(...options);
62
+ }
63
+
64
+ export function completeJevArguments(argText: string): CompletionItem[] | null {
65
+ const endsWithSpace = /\s$/.test(argText);
66
+ const tokens = argText.trim().split(/\s+/).filter(Boolean);
67
+ const prior = (endsWithSpace ? tokens : tokens.slice(0, -1)).filter((token) => !FLAG_RE.test(token));
68
+ const partial = endsWithSpace || tokens.length === 0 ? '' : tokens[tokens.length - 1];
69
+
70
+ // Completing the action itself.
71
+ if (prior.length === 0 && !endsWithSpace) {
72
+ return filterItems(ACTIONS, partial);
73
+ }
74
+ if (prior.length === 0) return ACTIONS; // space typed, nothing yet: offer the menu
75
+
76
+ const action = prior[0];
77
+ const flags = argText.trim().split(/\s+/).some((token) => FLAG_RE.test(token));
78
+ const suffix = flags ? ' -l' : '';
79
+
80
+ if (action === 'set' || action === 'get' || action === 'unset') {
81
+ if (prior.length === 1) {
82
+ const items = keyItems(action + suffix);
83
+ return filterItems(items, `${action}${suffix} ${partial}`.trim());
84
+ }
85
+ if (action === 'set' && prior.length === 2) {
86
+ const key = prior[1];
87
+ if (key === 'routing.cheap' || key === 'routing.strong') {
88
+ if (modelOptions.length === 0) return null;
89
+ const items = modelOptions.map((model) => ({
90
+ value: `set ${key} ${model.ref}${suffix}`,
91
+ label: model.name,
92
+ description: `${model.provider}${model.image ? ' · images' : ''}${model.reasoning ? ' · thinking' : ''}`,
93
+ }));
94
+ return filterItems(items, `set ${key}${suffix} ${partial}`.trim());
95
+ }
96
+ const options = VALUE_OPTIONS[key] ?? [];
97
+ if (options.length === 0) return null;
98
+ const items = options.map((option) => ({
99
+ value: `set ${key} ${option.label}${suffix}`,
100
+ label: option.label,
101
+ description: option.description,
102
+ }));
103
+ return filterItems(items, `set ${key}${suffix} ${partial}`.trim());
104
+ }
105
+ }
106
+ return null;
107
+ }
@@ -0,0 +1,61 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ import { completeJevArguments, setModelOptions, type CompletionItem } from './completions.js';
3
+ import { modelsFromContext } from './models.js';
4
+ import { runCli, type CliIo } from '../cli/main.js';
5
+ import { defaultConfigPaths } from '../shared/config.js';
6
+
7
+ /**
8
+ * Core of the `/jev` command: same engine as the `pi-jev` CLI. Split out so
9
+ * tests can drive it against temp config files.
10
+ */
11
+ export function runJevCommand(rawArgs: string, io: CliIo): number {
12
+ const argv = rawArgs.trim().split(/\s+/).filter(Boolean);
13
+ if (argv[0] === 'config') argv.shift(); // tolerate `/jev config ...`
14
+ return runCli(argv.length > 0 ? ['config', ...argv] : ['config'], io);
15
+ }
16
+
17
+ /**
18
+ * The command extension: `/jev` configures the suite from inside pi.
19
+ * Config is re-read by every hook, so changes apply without a restart.
20
+ *
21
+ * /jev show resolved values + each value's source
22
+ * /jev set <key> <value> write a key (`-l` targets the project file)
23
+ * /jev get <key> /jev unset <key> /jev keys /jev path
24
+ */
25
+ export default function (pi: ExtensionAPI): void {
26
+ pi.on('session_start', (_event, ctx) => {
27
+ setModelOptions(modelsFromContext(ctx));
28
+ });
29
+
30
+ pi.registerCommand('jev', {
31
+ description: 'Configure pi-jev: /jev [set|get|unset|keys|path] ...',
32
+ getArgumentCompletions: (prefix: string): CompletionItem[] | null => completeJevArguments(prefix),
33
+ handler: async (args, ctx) => {
34
+ const paths = defaultConfigPaths();
35
+ if (!(args ?? '').trim()) {
36
+ // Bare /jev opens the interactive settings menu (TUI mode only).
37
+ const { openJevSettingsMenu } = await import('./menu.js');
38
+ await openJevSettingsMenu(ctx, paths, {
39
+ out: (text) => {
40
+ ctx.ui.notify(text, 'info');
41
+ },
42
+ });
43
+ return;
44
+ }
45
+ const lines: string[] = [];
46
+ const errs: string[] = [];
47
+ const code = runJevCommand(args ?? '', {
48
+ env: process.env,
49
+ ...defaultConfigPaths(),
50
+ out: (text) => {
51
+ lines.push(text);
52
+ },
53
+ err: (text) => {
54
+ errs.push(text);
55
+ },
56
+ });
57
+ const text = [...lines, ...errs].join('\n').trim();
58
+ ctx.ui.notify(text || '(no output)', code === 0 ? 'info' : 'error');
59
+ },
60
+ });
61
+ }