@mrclrchtr/supi-insights 0.1.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.
- package/README.md +234 -0
- package/node_modules/@mrclrchtr/supi-core/README.md +90 -0
- package/node_modules/@mrclrchtr/supi-core/package.json +30 -0
- package/node_modules/@mrclrchtr/supi-core/src/config-settings.ts +76 -0
- package/node_modules/@mrclrchtr/supi-core/src/config.ts +186 -0
- package/node_modules/@mrclrchtr/supi-core/src/context-messages.ts +119 -0
- package/node_modules/@mrclrchtr/supi-core/src/context-provider-registry.ts +36 -0
- package/node_modules/@mrclrchtr/supi-core/src/context-tag.ts +31 -0
- package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +255 -0
- package/node_modules/@mrclrchtr/supi-core/src/index.ts +83 -0
- package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
- package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +54 -0
- package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +29 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings-command.ts +15 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings-registry.ts +41 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings-ui.ts +226 -0
- package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
- package/package.json +47 -0
- package/src/aggregator.ts +245 -0
- package/src/cache.ts +94 -0
- package/src/extractor.ts +189 -0
- package/src/generator.ts +395 -0
- package/src/html.ts +481 -0
- package/src/index.ts +1 -0
- package/src/insights.ts +416 -0
- package/src/parser.ts +373 -0
- package/src/report.css +411 -0
- package/src/report.js +35 -0
- package/src/scanner.ts +13 -0
- package/src/types.ts +114 -0
- package/src/utils.ts +265 -0
package/README.md
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# @mrclrchtr/supi-insights
|
|
2
|
+
|
|
3
|
+
> Usage insights and analytics for [pi](https://pi.dev) sessions. Inspired by Claude Code's `/insights` command, rebuilt for pi's extension architecture.
|
|
4
|
+
|
|
5
|
+
Generate rich, shareable HTML reports analyzing your PI coding sessions — what you work on, how you interact with the agent, what works well, where friction happens, and what to try next.
|
|
6
|
+
|
|
7
|
+
## What you get
|
|
8
|
+
|
|
9
|
+
Running `/supi-insights` produces a report with:
|
|
10
|
+
|
|
11
|
+
- **At a Glance** — high-level summary of what's working, what's hindering you, quick wins, and ambitious workflows for future models
|
|
12
|
+
- **What You Work On** — project areas with session counts and descriptions
|
|
13
|
+
- **How You Use PI** — narrative analysis of your interaction style and key patterns
|
|
14
|
+
- **Impressive Things You Did** — notable workflows and accomplishments
|
|
15
|
+
- **Where Things Go Wrong** — friction categories with concrete examples
|
|
16
|
+
- **Charts & Stats** — tool usage, languages, session types, outcomes, satisfaction, response times, time-of-day patterns, tool errors, multi-session usage
|
|
17
|
+
- **Suggestions** — CLAUDE.md additions, features to try, new usage patterns
|
|
18
|
+
- **On the Horizon** — ambitious workflows to prepare for as models improve
|
|
19
|
+
|
|
20
|
+
Reports are saved as self-contained HTML files you can open in any browser.
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pi install npm:@mrclrchtr/supi-insights
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
> **🧪 Beta package** — not included in the `@mrclrchtr/supi` meta-package.
|
|
29
|
+
> Install directly when you need session analytics.
|
|
30
|
+
|
|
31
|
+
Or install from a local checkout with `pi install /path/to/packages/supi-insights`.
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
Type `/supi-insights` in the pi editor and press Enter.
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
/supi-insights
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The extension will:
|
|
42
|
+
|
|
43
|
+
1. **Scan** all historical pi sessions across projects
|
|
44
|
+
2. **Extract metadata** — tool counts, languages, git activity, lines changed, response times, errors (cached for future runs)
|
|
45
|
+
3. **Extract qualitative facets** — goals, outcomes, satisfaction, friction via LLM analysis (cached)
|
|
46
|
+
4. **Generate narrative insights** — coaching-style analysis in 7 parallel sections
|
|
47
|
+
5. **Render an HTML report** — saved to `~/.pi/agent/supi/insights/report-{timestamp}.html`
|
|
48
|
+
6. **Show a summary** — in the PI chat with a link to the full report
|
|
49
|
+
|
|
50
|
+
### First run
|
|
51
|
+
|
|
52
|
+
The first run may take a minute or two if you have many sessions, because it:
|
|
53
|
+
- Parses all session JSONL files
|
|
54
|
+
- Extracts metadata for each session
|
|
55
|
+
- Runs ~50 LLM facet extractions (batched, 50 concurrent)
|
|
56
|
+
- Generates ~8 LLM insight sections
|
|
57
|
+
|
|
58
|
+
Subsequent runs are fast — cached metadata and facets are reused.
|
|
59
|
+
|
|
60
|
+
## Configuration
|
|
61
|
+
|
|
62
|
+
If your install surface includes `/supi-settings` (for example when also installing the `@mrclrchtr/supi` meta-package), this package contributes an **Insights** section there. You can also edit `~/.pi/agent/supi/config.json` directly:
|
|
63
|
+
|
|
64
|
+
| Setting | Description | Default |
|
|
65
|
+
|---------|-------------|---------|
|
|
66
|
+
| `enabled` | Enable or disable insights generation | `on` |
|
|
67
|
+
| `maxSessions` | Maximum sessions to fully parse and analyze | `200` |
|
|
68
|
+
| `maxFacets` | Maximum per-session LLM facet extractions | `50` |
|
|
69
|
+
|
|
70
|
+
Example config:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"insights": {
|
|
75
|
+
"enabled": true,
|
|
76
|
+
"maxSessions": 200,
|
|
77
|
+
"maxFacets": 50
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Architecture
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
packages/supi-insights/src/
|
|
86
|
+
├── insights.ts # Extension factory — registers /supi-insights and settings
|
|
87
|
+
├── scanner.ts # Session discovery via SessionManager.listAll()
|
|
88
|
+
├── parser.ts # JSONL parsing, transcript extraction, tool stat aggregation
|
|
89
|
+
├── extractor.ts # LLM facet extraction via @earendil-works/pi-ai/complete()
|
|
90
|
+
├── aggregator.ts # Pure data aggregation + multi-clauding detection
|
|
91
|
+
├── generator.ts # Parallel narrative insight generation (7 sections)
|
|
92
|
+
├── html.ts # HTML report renderer with CSS bar charts
|
|
93
|
+
├── cache.ts # Facet and metadata caching
|
|
94
|
+
├── utils.ts # Chart helpers, label mappings, text utilities
|
|
95
|
+
└── types.ts # Shared TypeScript types
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Data flow
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
SessionManager.listAll()
|
|
102
|
+
│
|
|
103
|
+
▼
|
|
104
|
+
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
|
105
|
+
│ scanner │────▶│ parser │────▶│ cache │
|
|
106
|
+
└─────────────┘ └─────────────┘ └─────────────┘
|
|
107
|
+
│ │
|
|
108
|
+
│ ┌─────────────┐ │
|
|
109
|
+
└────────▶│ extractor │◀───────────┘
|
|
110
|
+
│ (LLM facets)│
|
|
111
|
+
└─────────────┘
|
|
112
|
+
│
|
|
113
|
+
▼
|
|
114
|
+
┌─────────────┐
|
|
115
|
+
│ aggregator │
|
|
116
|
+
└─────────────┘
|
|
117
|
+
│
|
|
118
|
+
▼
|
|
119
|
+
┌─────────────┐
|
|
120
|
+
│ generator │
|
|
121
|
+
│ (insights) │
|
|
122
|
+
└─────────────┘
|
|
123
|
+
│
|
|
124
|
+
▼
|
|
125
|
+
┌─────────────┐
|
|
126
|
+
│ html │
|
|
127
|
+
│ report │
|
|
128
|
+
└─────────────┘
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Key design decisions
|
|
132
|
+
|
|
133
|
+
**Direct LLM access** — Uses `@earendil-works/pi-ai/complete()` and `ctx.modelRegistry.getApiKeyAndHeaders()` to make API calls with the user's already-configured keys. No external SDK needed.
|
|
134
|
+
|
|
135
|
+
**Aggressive caching** — Session metadata and LLM-extracted facets are cached in `~/.pi/agent/supi/insights/`. Cache keys include the session file path and modified timestamp, so branch files do not collide and resumed sessions are reprocessed.
|
|
136
|
+
|
|
137
|
+
**Branch deduplication** — pi session files are append-only trees. The extension analyzes the active branch path, then keeps only the branch/file with the most user messages per session ID to avoid double-counting.
|
|
138
|
+
|
|
139
|
+
**Substantive filtering** — Sessions with fewer than 2 user messages or lasting under 1 minute are skipped, as are sessions where the only goal is `warmup_minimal`.
|
|
140
|
+
|
|
141
|
+
**Parallel processing** — Facet extractions run in batches of 50 concurrent LLM calls. Insight sections run in parallel too, with `atAGlance` generated last (it consumes outputs from all other sections).
|
|
142
|
+
|
|
143
|
+
## Caching
|
|
144
|
+
|
|
145
|
+
Cached data lives in `~/.pi/agent/supi/insights/`:
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
~/.pi/agent/supi/insights/
|
|
149
|
+
├── meta/
|
|
150
|
+
│ ├── {session-id}_{path-hash}_{modified-hash}.json # Extracted metadata
|
|
151
|
+
│ └── ...
|
|
152
|
+
├── facets/
|
|
153
|
+
│ ├── {session-id}_{path-hash}_{modified-hash}.json # LLM-extracted facets
|
|
154
|
+
│ └── ...
|
|
155
|
+
└── report-{timestamp}.html # Generated HTML reports
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
- **Metadata cache** includes: tool counts, languages, git activity, tokens, lines changed, response times, errors, feature flags
|
|
159
|
+
- **Facet cache** includes: goals, outcomes, satisfaction, friction, success factors, brief summaries
|
|
160
|
+
|
|
161
|
+
To force a full re-analysis, delete the cache directory:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
rm -rf ~/.pi/agent/supi/insights/meta ~/.pi/agent/supi/insights/facets
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Multi-session detection
|
|
168
|
+
|
|
169
|
+
The extension detects when you run multiple pi sessions simultaneously ("multi-clauding") using a sliding-window algorithm:
|
|
170
|
+
|
|
171
|
+
- Collects all user message timestamps across sessions
|
|
172
|
+
- Looks for the pattern `sessionA → sessionB → sessionA` within a 30-minute window
|
|
173
|
+
- Reports overlap events, sessions involved, and percentage of messages during overlaps
|
|
174
|
+
|
|
175
|
+
## Statistics tracked
|
|
176
|
+
|
|
177
|
+
### Per-session
|
|
178
|
+
- Tool usage counts
|
|
179
|
+
- Programming languages used (from file paths in edit/write tool calls)
|
|
180
|
+
- Git commits and pushes
|
|
181
|
+
- Input/output tokens
|
|
182
|
+
- Lines added/removed (via diff)
|
|
183
|
+
- Files modified
|
|
184
|
+
- User response times (time between assistant message and next user message)
|
|
185
|
+
- Tool errors with categorization (Command Failed, Edit Failed, User Rejected, etc.)
|
|
186
|
+
- User interruptions
|
|
187
|
+
- Feature usage (task agents, MCP, web search, web fetch)
|
|
188
|
+
- Message timestamps for time-of-day analysis
|
|
189
|
+
|
|
190
|
+
### Aggregated
|
|
191
|
+
- Total sessions, messages, duration, tokens
|
|
192
|
+
- Days active, messages per day
|
|
193
|
+
- Top tools, languages, goals, outcomes
|
|
194
|
+
- Satisfaction and helpfulness distributions
|
|
195
|
+
- Friction types and success factors
|
|
196
|
+
- Response time histograms
|
|
197
|
+
- Time-of-day patterns
|
|
198
|
+
- Multi-session overlap events
|
|
199
|
+
|
|
200
|
+
## Compared to Claude Code `/insights`
|
|
201
|
+
|
|
202
|
+
| Feature | Claude Code | /supi-insights |
|
|
203
|
+
|---------|-------------|---------------|
|
|
204
|
+
| Session discovery | Manual filesystem scan | `SessionManager.listAll()` |
|
|
205
|
+
| LLM access | Internal `queryWithModel()` | `@earendil-works/pi-ai/complete()` |
|
|
206
|
+
| Output | HTML report + browser | HTML report + browser |
|
|
207
|
+
| Caching | Custom `~/.claude/usage-data/` | `~/.pi/agent/supi/insights/` |
|
|
208
|
+
| Multi-clauding | ✅ | ✅ |
|
|
209
|
+
| Remote host collection | ✅ (ant-only, SCP) | ❌ (not applicable) |
|
|
210
|
+
| Team feedback (ant-only) | ✅ | ❌ |
|
|
211
|
+
| TUI dashboard | ❌ | Planned |
|
|
212
|
+
| Live tracking | ❌ | Planned |
|
|
213
|
+
|
|
214
|
+
## Development
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
# Typecheck
|
|
218
|
+
pnpm exec tsc --noEmit -p packages/supi-insights/tsconfig.json
|
|
219
|
+
|
|
220
|
+
# Test
|
|
221
|
+
pnpm vitest run packages/supi-insights/
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## Roadmap
|
|
225
|
+
|
|
226
|
+
- [ ] **Live tracking** — accumulate stats via `tool_call`, `turn_end`, `model_select` events instead of only scanning historical sessions
|
|
227
|
+
- [ ] **TUI overlay dashboard** — native PI terminal UI with ASCII bar charts, keyboard-navigable sections
|
|
228
|
+
- [ ] **Export formats** — Markdown, JSON, CSV
|
|
229
|
+
- [ ] **Trend comparison** — compare current report with previous reports
|
|
230
|
+
- [ ] **Session drill-down** — `/supi-insights --session <id>` to analyze a specific session
|
|
231
|
+
|
|
232
|
+
## License
|
|
233
|
+
|
|
234
|
+
MIT
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# @mrclrchtr/supi-core
|
|
2
|
+
|
|
3
|
+
Shared infrastructure for SuPi packages.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Use it as a dependency in another extension package:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add @mrclrchtr/supi-core
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Package role
|
|
14
|
+
|
|
15
|
+
`@mrclrchtr/supi-core` is a library package. It does **not** register a pi extension and is not meant to be installed as a standalone pi package.
|
|
16
|
+
|
|
17
|
+
## What it provides
|
|
18
|
+
|
|
19
|
+
Current exports cover:
|
|
20
|
+
|
|
21
|
+
- shared config loading, scoped reads, writes, and key removal
|
|
22
|
+
- config-backed settings registration helpers for `/supi-settings`
|
|
23
|
+
- the shared settings registry, overlay UI, and `registerSettingsCommand()` helper
|
|
24
|
+
- XML `<extension-context>` wrapping plus context-message utilities
|
|
25
|
+
- context-provider and debug-event registries reused across SuPi packages
|
|
26
|
+
- project root and path helpers reused by packages such as `supi-lsp`
|
|
27
|
+
|
|
28
|
+
## Config system
|
|
29
|
+
|
|
30
|
+
Config resolution order:
|
|
31
|
+
|
|
32
|
+
```text
|
|
33
|
+
defaults <- global <- project
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Config file locations:
|
|
37
|
+
|
|
38
|
+
- global: `~/.pi/agent/supi/config.json`
|
|
39
|
+
- project: `.pi/supi/config.json`
|
|
40
|
+
|
|
41
|
+
Main helpers:
|
|
42
|
+
|
|
43
|
+
- `loadSupiConfig()` — effective merged config (`defaults <- global <- project`)
|
|
44
|
+
- `loadSupiConfigForScope()` — raw single-scope config for settings UIs (`defaults <- selected scope`)
|
|
45
|
+
- `writeSupiConfig()`
|
|
46
|
+
- `removeSupiConfigKey()`
|
|
47
|
+
- `registerConfigSettings()`
|
|
48
|
+
|
|
49
|
+
## Context and settings helpers
|
|
50
|
+
|
|
51
|
+
- `wrapExtensionContext()`
|
|
52
|
+
- `findLastUserMessageIndex()`
|
|
53
|
+
- `getContextToken()`
|
|
54
|
+
- `pruneAndReorderContextMessages()`
|
|
55
|
+
- `registerSettings()`
|
|
56
|
+
- `registerSettingsCommand()`
|
|
57
|
+
- `openSettingsOverlay()`
|
|
58
|
+
|
|
59
|
+
## Example
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { loadSupiConfig, registerConfigSettings, wrapExtensionContext } from "@mrclrchtr/supi-core";
|
|
63
|
+
|
|
64
|
+
const config = loadSupiConfig("my-extension", process.cwd(), {
|
|
65
|
+
enabled: true,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
registerConfigSettings({
|
|
69
|
+
id: "my-extension",
|
|
70
|
+
label: "My Extension",
|
|
71
|
+
section: "my-extension",
|
|
72
|
+
defaults: { enabled: true },
|
|
73
|
+
buildItems: () => [],
|
|
74
|
+
persistChange: () => {},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const message = wrapExtensionContext("my-extension", "hello", {
|
|
78
|
+
turn: 1,
|
|
79
|
+
file: "CLAUDE.md",
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Requirements
|
|
84
|
+
|
|
85
|
+
- `@earendil-works/pi-coding-agent`
|
|
86
|
+
- `@earendil-works/pi-tui`
|
|
87
|
+
|
|
88
|
+
## Source
|
|
89
|
+
|
|
90
|
+
- Main exports: `src/index.ts`
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mrclrchtr/supi-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "SuPi core — shared infrastructure for SuPi extensions (XML context tags, config system)",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/mrclrchtr/supi.git"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"pi",
|
|
15
|
+
"pi-coding-agent"
|
|
16
|
+
],
|
|
17
|
+
"files": [
|
|
18
|
+
"src/**/*.ts",
|
|
19
|
+
"!__tests__"
|
|
20
|
+
],
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
23
|
+
"@earendil-works/pi-tui": "*"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^25.6.0",
|
|
27
|
+
"vitest": "^4.1.4"
|
|
28
|
+
},
|
|
29
|
+
"main": "src/index.ts"
|
|
30
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Config-aware settings helper for SuPi config-backed settings sections.
|
|
2
|
+
// Wraps registerSettings() and centralizes selected-scope loading + scoped persistence.
|
|
3
|
+
|
|
4
|
+
import type { SettingItem } from "@earendil-works/pi-tui";
|
|
5
|
+
import { loadSupiConfigForScope, removeSupiConfigKey, writeSupiConfig } from "./config.ts";
|
|
6
|
+
import type { SettingsScope } from "./settings-registry.ts";
|
|
7
|
+
import { registerSettings } from "./settings-registry.ts";
|
|
8
|
+
|
|
9
|
+
export interface ConfigSettingsHelpers {
|
|
10
|
+
/** Write a key to the selected scope's config section. */
|
|
11
|
+
set(key: string, value: unknown): void;
|
|
12
|
+
/** Remove a key from the selected scope's config section. */
|
|
13
|
+
unset(key: string): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ConfigSettingsOptions<T> {
|
|
17
|
+
/** Extension identifier — e.g. "lsp", "claude-md" */
|
|
18
|
+
id: string;
|
|
19
|
+
/** Human-readable label shown in the UI */
|
|
20
|
+
label: string;
|
|
21
|
+
/** SuPi config section name — e.g. "lsp", "claude-md" */
|
|
22
|
+
section: string;
|
|
23
|
+
/** Default config values */
|
|
24
|
+
defaults: T;
|
|
25
|
+
/** Build SettingItem[] from scoped config. Called by loadValues. */
|
|
26
|
+
buildItems: (settings: T, scope: SettingsScope, cwd: string) => SettingItem[];
|
|
27
|
+
/** Handle a settings change with scoped persistence helpers. */
|
|
28
|
+
persistChange: (
|
|
29
|
+
scope: SettingsScope,
|
|
30
|
+
cwd: string,
|
|
31
|
+
settingId: string,
|
|
32
|
+
value: string,
|
|
33
|
+
helpers: ConfigSettingsHelpers,
|
|
34
|
+
) => void;
|
|
35
|
+
/** Optional home directory for config resolution (testing). */
|
|
36
|
+
homeDir?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Register a config-backed settings section.
|
|
41
|
+
*
|
|
42
|
+
* Loads display values from the selected scope only (`defaults <- selected scope`)
|
|
43
|
+
* instead of merged effective runtime config. Provides scoped `set` / `unset`
|
|
44
|
+
* persistence helpers so extensions don't need to wire `writeSupiConfig` /
|
|
45
|
+
* `removeSupiConfigKey` by hand.
|
|
46
|
+
*/
|
|
47
|
+
export function registerConfigSettings<T>(options: ConfigSettingsOptions<T>): void {
|
|
48
|
+
registerSettings({
|
|
49
|
+
id: options.id,
|
|
50
|
+
label: options.label,
|
|
51
|
+
loadValues: (scope, cwd) => {
|
|
52
|
+
const settings = loadSupiConfigForScope(options.section, cwd, options.defaults, {
|
|
53
|
+
scope,
|
|
54
|
+
homeDir: options.homeDir,
|
|
55
|
+
});
|
|
56
|
+
return options.buildItems(settings, scope, cwd);
|
|
57
|
+
},
|
|
58
|
+
persistChange: (scope, cwd, settingId, value) => {
|
|
59
|
+
const helpers: ConfigSettingsHelpers = {
|
|
60
|
+
set: (key, val) => {
|
|
61
|
+
writeSupiConfig(
|
|
62
|
+
{ section: options.section, scope, cwd },
|
|
63
|
+
{ [key]: val },
|
|
64
|
+
{ homeDir: options.homeDir },
|
|
65
|
+
);
|
|
66
|
+
},
|
|
67
|
+
unset: (key) => {
|
|
68
|
+
removeSupiConfigKey({ section: options.section, scope, cwd }, key, {
|
|
69
|
+
homeDir: options.homeDir,
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
options.persistChange(scope, cwd, settingId, value, helpers);
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Shared config system for SuPi extensions.
|
|
2
|
+
//
|
|
3
|
+
// Global config: ~/.pi/agent/supi/config.json
|
|
4
|
+
// Project config: .pi/supi/config.json (relative to cwd)
|
|
5
|
+
// Resolution: hardcoded defaults ← global ← project
|
|
6
|
+
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as os from "node:os";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
|
|
11
|
+
const GLOBAL_CONFIG_DIR = ".pi/agent/supi";
|
|
12
|
+
const PROJECT_CONFIG_DIR = ".pi/supi";
|
|
13
|
+
const CONFIG_FILE = "config.json";
|
|
14
|
+
|
|
15
|
+
function getGlobalConfigPath(homeDir?: string): string {
|
|
16
|
+
return path.join(homeDir ?? os.homedir(), GLOBAL_CONFIG_DIR, CONFIG_FILE);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getProjectConfigPath(cwd: string): string {
|
|
20
|
+
return path.join(cwd, PROJECT_CONFIG_DIR, CONFIG_FILE);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readJsonFile(filePath: string): Record<string, unknown> | null {
|
|
24
|
+
let content: string;
|
|
25
|
+
try {
|
|
26
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
27
|
+
} catch {
|
|
28
|
+
// ENOENT or permission error — silent, file may not exist
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let parsed: unknown;
|
|
33
|
+
try {
|
|
34
|
+
parsed = JSON.parse(content);
|
|
35
|
+
} catch {
|
|
36
|
+
// biome-ignore lint/suspicious/noConsole: deliberate config parse warning
|
|
37
|
+
console.warn(`[supi-core] Failed to parse config file, ignoring: ${filePath}`);
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
42
|
+
return parsed as Record<string, unknown>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// biome-ignore lint/suspicious/noConsole: deliberate config parse warning
|
|
46
|
+
console.warn(`[supi-core] Config file root is not an object, ignoring: ${filePath}`);
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function shallowMerge<T>(base: T, ...overrides: Array<Record<string, unknown> | null>): T {
|
|
51
|
+
let result = { ...base };
|
|
52
|
+
for (const override of overrides) {
|
|
53
|
+
if (!override) continue;
|
|
54
|
+
result = { ...result, ...override };
|
|
55
|
+
}
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface SupiConfigOptions {
|
|
60
|
+
homeDir?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Load and merge config for a given extension section.
|
|
65
|
+
*
|
|
66
|
+
* Resolution order: defaults ← global ← project
|
|
67
|
+
*/
|
|
68
|
+
export function loadSupiConfig<T>(
|
|
69
|
+
section: string,
|
|
70
|
+
cwd: string,
|
|
71
|
+
defaults: T,
|
|
72
|
+
options?: SupiConfigOptions,
|
|
73
|
+
): T {
|
|
74
|
+
const globalConfig = readJsonFile(getGlobalConfigPath(options?.homeDir));
|
|
75
|
+
const projectConfig = readJsonFile(getProjectConfigPath(cwd));
|
|
76
|
+
|
|
77
|
+
const globalSection = extractSection(globalConfig, section);
|
|
78
|
+
const projectSection = extractSection(projectConfig, section);
|
|
79
|
+
|
|
80
|
+
return shallowMerge(defaults, globalSection, projectSection);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Load config for a single scope only.
|
|
85
|
+
*
|
|
86
|
+
* Resolution order: defaults ← selected scope
|
|
87
|
+
*
|
|
88
|
+
* This is useful for settings UIs that need to show the raw values stored in
|
|
89
|
+
* one scope, rather than the effective merged config.
|
|
90
|
+
*/
|
|
91
|
+
export function loadSupiConfigForScope<T>(
|
|
92
|
+
section: string,
|
|
93
|
+
cwd: string,
|
|
94
|
+
defaults: T,
|
|
95
|
+
options: { scope: "global" | "project" } & SupiConfigOptions,
|
|
96
|
+
): T {
|
|
97
|
+
const config =
|
|
98
|
+
options.scope === "global"
|
|
99
|
+
? readJsonFile(getGlobalConfigPath(options.homeDir))
|
|
100
|
+
: readJsonFile(getProjectConfigPath(cwd));
|
|
101
|
+
|
|
102
|
+
const scopedSection = extractSection(config, section);
|
|
103
|
+
return shallowMerge(defaults, scopedSection);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface SupiConfigLocation {
|
|
107
|
+
section: string;
|
|
108
|
+
scope: "global" | "project";
|
|
109
|
+
cwd: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Write config values for a given extension section.
|
|
114
|
+
*/
|
|
115
|
+
export function writeSupiConfig(
|
|
116
|
+
loc: SupiConfigLocation,
|
|
117
|
+
value: Record<string, unknown>,
|
|
118
|
+
options?: SupiConfigOptions,
|
|
119
|
+
): void {
|
|
120
|
+
const configPath =
|
|
121
|
+
loc.scope === "global" ? getGlobalConfigPath(options?.homeDir) : getProjectConfigPath(loc.cwd);
|
|
122
|
+
|
|
123
|
+
const dir = path.dirname(configPath);
|
|
124
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
125
|
+
|
|
126
|
+
const existing = readJsonFile(configPath) ?? {};
|
|
127
|
+
existing[loc.section] = {
|
|
128
|
+
...((existing[loc.section] as Record<string, unknown>) ?? {}),
|
|
129
|
+
...value,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
fs.writeFileSync(configPath, `${JSON.stringify(existing, null, 2)}\n`, "utf-8");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Remove a key from a config section.
|
|
137
|
+
* Used by `interval default` to remove the project override.
|
|
138
|
+
*/
|
|
139
|
+
export function removeSupiConfigKey(
|
|
140
|
+
loc: SupiConfigLocation,
|
|
141
|
+
key: string,
|
|
142
|
+
options?: SupiConfigOptions,
|
|
143
|
+
): void {
|
|
144
|
+
const configPath =
|
|
145
|
+
loc.scope === "global" ? getGlobalConfigPath(options?.homeDir) : getProjectConfigPath(loc.cwd);
|
|
146
|
+
|
|
147
|
+
const existing = readJsonFile(configPath);
|
|
148
|
+
if (!existing) return;
|
|
149
|
+
|
|
150
|
+
const sectionData = existing[loc.section] as Record<string, unknown> | undefined;
|
|
151
|
+
if (!sectionData) return;
|
|
152
|
+
|
|
153
|
+
delete sectionData[key];
|
|
154
|
+
|
|
155
|
+
if (Object.keys(sectionData).length === 0) {
|
|
156
|
+
delete existing[loc.section];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const dir = path.dirname(configPath);
|
|
160
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
161
|
+
|
|
162
|
+
const content = Object.keys(existing).length > 0 ? `${JSON.stringify(existing, null, 2)}\n` : "";
|
|
163
|
+
|
|
164
|
+
if (content) {
|
|
165
|
+
// Directory guaranteed to exist since we just read from it
|
|
166
|
+
fs.writeFileSync(configPath, content, "utf-8");
|
|
167
|
+
} else {
|
|
168
|
+
try {
|
|
169
|
+
fs.unlinkSync(configPath);
|
|
170
|
+
} catch {
|
|
171
|
+
// File may not exist
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function extractSection(
|
|
177
|
+
config: Record<string, unknown> | null,
|
|
178
|
+
section: string,
|
|
179
|
+
): Record<string, unknown> | null {
|
|
180
|
+
if (!config) return null;
|
|
181
|
+
const data = config[section];
|
|
182
|
+
if (typeof data === "object" && data !== null && !Array.isArray(data)) {
|
|
183
|
+
return data as Record<string, unknown>;
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|