@mrclrchtr/supi-review 1.10.0 → 1.11.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 +42 -10
- package/node_modules/@mrclrchtr/supi-core/README.md +103 -0
- package/node_modules/@mrclrchtr/supi-core/package.json +59 -0
- package/node_modules/@mrclrchtr/supi-core/src/api.ts +32 -0
- package/node_modules/@mrclrchtr/supi-core/src/config/config-settings.ts +182 -0
- package/node_modules/@mrclrchtr/supi-core/src/config/config.ts +206 -0
- package/node_modules/@mrclrchtr/supi-core/src/config.ts +11 -0
- package/node_modules/@mrclrchtr/supi-core/src/context/context-messages.ts +119 -0
- package/node_modules/@mrclrchtr/supi-core/src/context/context-provider-registry.ts +36 -0
- package/node_modules/@mrclrchtr/supi-core/src/context/context-tag.ts +31 -0
- package/node_modules/@mrclrchtr/supi-core/src/context.ts +16 -0
- package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +255 -0
- package/node_modules/@mrclrchtr/supi-core/src/index.ts +30 -0
- package/node_modules/@mrclrchtr/supi-core/src/llm.ts +211 -0
- package/node_modules/@mrclrchtr/supi-core/src/path-utils.ts +40 -0
- package/node_modules/@mrclrchtr/supi-core/src/path.ts +2 -0
- package/{src/ui → node_modules/@mrclrchtr/supi-core/src}/progress-widget.ts +32 -10
- package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
- package/node_modules/@mrclrchtr/supi-core/src/project.ts +15 -0
- package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +86 -0
- package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +29 -0
- package/node_modules/@mrclrchtr/supi-core/src/session.ts +4 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-command.ts +15 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-registry.ts +41 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-ui.ts +226 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings-ui.ts +2 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings.ts +9 -0
- package/node_modules/@mrclrchtr/supi-core/src/substrate-types.ts +11 -0
- package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
- package/node_modules/@mrclrchtr/supi-core/src/tool-framework.ts +182 -0
- package/node_modules/@mrclrchtr/supi-core/src/types.ts +2 -0
- package/package.json +7 -1
- package/src/history/synthesize.ts +13 -0
- package/src/review-result.ts +98 -0
- package/src/review.ts +90 -147
- package/src/target/packet.ts +81 -17
- package/src/target/review-instruction-blocks.ts +60 -0
- package/src/tool/brief-runner.ts +1 -1
- package/src/tool/review-runner.ts +152 -34
- package/src/tool/schemas.ts +55 -12
- package/src/types.ts +81 -9
- package/src/ui/flow.ts +10 -166
- package/src/ui/format-content.ts +81 -33
- package/src/ui/renderer.ts +103 -51
- package/src/ui/review-plan-inspector.ts +396 -0
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ After install, pi gets one command:
|
|
|
33
33
|
The reviewer runs in managed child agent sessions:
|
|
34
34
|
|
|
35
35
|
- a **brief synthesizer** creates a structured review brief from the active session branch
|
|
36
|
-
- a **read-only reviewer** inspects the selected snapshot (without receiving bulk inline diffs) and submits structured
|
|
36
|
+
- a **read-only reviewer** inspects the selected snapshot (without receiving bulk inline diffs) and submits structured review items
|
|
37
37
|
|
|
38
38
|

|
|
39
39
|
|
|
@@ -52,10 +52,10 @@ The reviewer runs in managed child agent sessions:
|
|
|
52
52
|
3. optionally add a short note
|
|
53
53
|
4. resolve the snapshot
|
|
54
54
|
5. synthesize a review brief from the current session history
|
|
55
|
-
6. preview the synthesized brief + compact prompt preview
|
|
55
|
+
6. preview the synthesized brief + compact prompt preview, then press `v` for an in-app inspector (Overview first, Raw Prompt via `tab`, export via `e`)
|
|
56
56
|
7. the reviewer fetches per-file diffs on demand via snapshot-aware tools; live progress widget shows activity
|
|
57
|
-
8.
|
|
58
|
-
9. if
|
|
57
|
+
8. normalize the submitted review items into a host-derived verdict + structured result
|
|
58
|
+
9. if review items exist, hand off to the main agent so it can ask what to do next with fixed options (`Fix all`, `Fix selected`, `Verify findings`, `Skip`)
|
|
59
59
|
|
|
60
60
|
## Review targets
|
|
61
61
|
|
|
@@ -81,13 +81,27 @@ Before the actual review starts, the package:
|
|
|
81
81
|
- focus areas
|
|
82
82
|
- risky files
|
|
83
83
|
- unresolved questions
|
|
84
|
+
- `reviewInstructionBlockIds` selected from a fixed host-owned catalog
|
|
84
85
|
|
|
85
86
|
The synthesizer also receives a bounded diff excerpt from the snapshot so it can reason about actual code changes, not just filenames.
|
|
86
87
|
|
|
87
|
-
That synthesized brief is then combined with the git snapshot into a compact reviewer prompt. The prompt contains the brief, file manifest,
|
|
88
|
+
That synthesized brief is then combined with the git snapshot into a compact reviewer prompt. The host owns a fixed catalog of review instruction blocks, and the brief selects zero or more block IDs from that catalog when extra review guidance is warranted. The resulting prompt contains the brief, file manifest, per-file overview, and any brief-selected **mandatory review instructions**, but no large inline diffs. Instead, the reviewer session gets snapshot-aware tools (`read_snapshot_diff`, `read_snapshot_file`) to fetch exact per-file diffs and before/after file contents on demand.
|
|
88
89
|
|
|
89
90
|
The session-transcript approach mirrors how Pi summarizes context for compaction: the entire resolved conversation is rendered in a readable label format and sent to the model as a whole, rather than relying on heuristic excerpt ranking.
|
|
90
91
|
|
|
92
|
+
## Review-plan inspector
|
|
93
|
+
|
|
94
|
+
Before the reviewer runs, the plan preview stays inside Pi:
|
|
95
|
+
|
|
96
|
+
- `v` opens an in-app inspector instead of spawning an external pager
|
|
97
|
+
- the inspector opens in **Overview** mode first
|
|
98
|
+
- `tab` toggles between **Overview** and **Raw Prompt**
|
|
99
|
+
- `↑↓` or `j` / `k` scroll long content in the inspector
|
|
100
|
+
- `q` or `esc` returns to the summary preview without canceling the review
|
|
101
|
+
- `e` exports the raw prompt to a temp file as a debugging fallback
|
|
102
|
+
|
|
103
|
+
The Overview mode uses the same structured packet data that feeds the reviewer prompt: mandatory review instructions, file overview rows, and truncated snapshot notes all come from shared packet derivation rather than re-parsing the raw prompt text.
|
|
104
|
+
|
|
91
105
|
## Model selection
|
|
92
106
|
|
|
93
107
|
Every `/supi-review` run asks you to choose the reviewer model.
|
|
@@ -101,20 +115,35 @@ Every `/supi-review` run asks you to choose the reviewer model.
|
|
|
101
115
|
|
|
102
116
|
A successful review includes:
|
|
103
117
|
|
|
104
|
-
-
|
|
118
|
+
- a host-derived binary verdict:
|
|
119
|
+
- `PATCH IS CORRECT`
|
|
120
|
+
- `PATCH HAS ISSUES`
|
|
105
121
|
- overall explanation
|
|
106
122
|
- overall confidence score
|
|
107
|
-
-
|
|
123
|
+
- normalized action/category summary counts
|
|
124
|
+
- structured review items with:
|
|
125
|
+
- title
|
|
126
|
+
- body
|
|
127
|
+
- category
|
|
128
|
+
- impact
|
|
129
|
+
- effort
|
|
130
|
+
- recommended action
|
|
131
|
+
- confidence score
|
|
132
|
+
- suggested fix
|
|
133
|
+
- verification hint
|
|
134
|
+
- optional code location
|
|
108
135
|
- the synthesized brief that drove the review
|
|
109
136
|
|
|
110
137
|
The renderer also handles failed, canceled, and timed-out reviews.
|
|
111
138
|
|
|
112
|
-
|
|
139
|
+
The reviewer model does **not** decide the final binary verdict directly. It submits review items plus overall explanation/confidence, then the host derives the verdict from the normalized items (`must-fix` items => `PATCH HAS ISSUES`).
|
|
140
|
+
|
|
141
|
+
When a successful review contains review items, `supi-review` also injects an agent-visible hidden follow-up message that asks the main agent to decide the next step with the user. If `ask_user` is available, the main agent is instructed to use it and offer:
|
|
113
142
|
|
|
114
|
-
- Done
|
|
115
143
|
- Fix all
|
|
116
144
|
- Fix selected
|
|
117
145
|
- Verify findings
|
|
146
|
+
- Skip
|
|
118
147
|
|
|
119
148
|
## Source
|
|
120
149
|
|
|
@@ -123,8 +152,11 @@ When a successful review contains findings, `supi-review` also injects an agent-
|
|
|
123
152
|
- `src/git.ts` — git snapshot resolution
|
|
124
153
|
- `src/history/collect.ts` — compaction-style session-context serialization
|
|
125
154
|
- `src/history/synthesize.ts` — brief synthesis orchestration
|
|
126
|
-
- `src/
|
|
155
|
+
- `src/review-result.ts` — review-item normalization, verdict derivation, and summary counts
|
|
156
|
+
- `src/target/review-instruction-blocks.ts` — fixed catalog of host-owned review instruction blocks
|
|
157
|
+
- `src/target/packet.ts` — final reviewer packet builder + shared preview-data derivation for the inspector
|
|
127
158
|
- `src/tool/brief-runner.ts` — brief synthesis child session
|
|
128
159
|
- `src/tool/review-runner.ts` — read-only reviewer child session with snapshot-aware tools
|
|
129
160
|
- `src/tool/snapshot-tools.ts` — per-file diff and before/after content tools scoped to the selected snapshot
|
|
161
|
+
- `src/ui/review-plan-inspector.ts` — in-app summary/inspector preview with Overview + Raw Prompt modes and export fallback
|
|
130
162
|
- `src/ui/renderer.ts` — structured result rendering
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<a href="https://github.com/mrclrchtr/supi/tree/main/packages/supi-core">
|
|
3
|
+
<picture>
|
|
4
|
+
<img src="https://raw.githubusercontent.com/mrclrchtr/supi/main/packages/supi-core/assets/logo.png" alt="SuPi" width="50%">
|
|
5
|
+
</picture>
|
|
6
|
+
</a>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
# @mrclrchtr/supi-core
|
|
10
|
+
|
|
11
|
+
Shared infrastructure for SuPi extensions.
|
|
12
|
+
|
|
13
|
+
This is a **pure library** — it does not register any pi commands or tools. The `/supi-settings` command is now available through `@mrclrchtr/supi-settings`.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pnpm add @mrclrchtr/supi-core
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Package surfaces
|
|
22
|
+
|
|
23
|
+
- `@mrclrchtr/supi-core/api` — reusable helpers for other packages and extensions
|
|
24
|
+
|
|
25
|
+
## What you get from the API
|
|
26
|
+
|
|
27
|
+
### Config helpers
|
|
28
|
+
|
|
29
|
+
- `loadSupiConfig()` — merged config with resolution order `defaults <- global <- project`
|
|
30
|
+
- `loadSupiConfigForScope()` — load one scope at a time for settings UIs
|
|
31
|
+
- `writeSupiConfig()` — persist values
|
|
32
|
+
- `removeSupiConfigKey()` — remove a key or override
|
|
33
|
+
|
|
34
|
+
Config file locations:
|
|
35
|
+
|
|
36
|
+
- global: `~/.pi/agent/supi/config.json`
|
|
37
|
+
- project: `.pi/supi/config.json`
|
|
38
|
+
|
|
39
|
+
### Settings helpers
|
|
40
|
+
|
|
41
|
+
- `registerSettings()` — register an arbitrary settings section
|
|
42
|
+
- `registerConfigSettings()` — register a config-backed settings section with scoped persistence helpers
|
|
43
|
+
- `registerSettingsCommand()` — register `/supi-settings`
|
|
44
|
+
- `openSettingsOverlay()` — open the shared settings UI directly
|
|
45
|
+
- `createInputSubmenu()` — helper for simple text-entry submenus
|
|
46
|
+
|
|
47
|
+
The built-in settings UI supports:
|
|
48
|
+
|
|
49
|
+
- project/global scope toggle
|
|
50
|
+
- grouped extension sections
|
|
51
|
+
- searchable setting lists
|
|
52
|
+
|
|
53
|
+
### Context helpers
|
|
54
|
+
|
|
55
|
+
- `wrapExtensionContext()` — wrap injected text in SuPi's `<extension-context>` tag
|
|
56
|
+
- `findLastUserMessageIndex()`
|
|
57
|
+
- `getContextToken()`
|
|
58
|
+
- `getPromptContent()`
|
|
59
|
+
- `pruneAndReorderContextMessages()`
|
|
60
|
+
- `restorePromptContent()`
|
|
61
|
+
|
|
62
|
+
### Shared registries
|
|
63
|
+
|
|
64
|
+
- context-provider registry for `/supi-context`
|
|
65
|
+
- debug-event registry for producers that want shared debug capture
|
|
66
|
+
- settings registry used by `/supi-settings`
|
|
67
|
+
|
|
68
|
+
### Project and session helpers
|
|
69
|
+
|
|
70
|
+
- project-root detection and directory walking helpers such as `findProjectRoot()` and `walkProject()`
|
|
71
|
+
- active-branch session helper: `getActiveBranchEntries()`
|
|
72
|
+
- terminal helpers such as `formatTitle()`, `signalWaiting()`, and `signalDone()`
|
|
73
|
+
|
|
74
|
+
## Example
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { loadSupiConfig, registerConfigSettings, wrapExtensionContext } from "@mrclrchtr/supi-core/api";
|
|
78
|
+
|
|
79
|
+
const config = loadSupiConfig("my-extension", process.cwd(), {
|
|
80
|
+
enabled: true,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
registerConfigSettings({
|
|
84
|
+
id: "my-extension",
|
|
85
|
+
label: "My Extension",
|
|
86
|
+
section: "my-extension",
|
|
87
|
+
defaults: { enabled: true },
|
|
88
|
+
buildItems: () => [],
|
|
89
|
+
persistChange: () => {},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const message = wrapExtensionContext("my-extension", "hello", {
|
|
93
|
+
file: "CLAUDE.md",
|
|
94
|
+
turn: 1,
|
|
95
|
+
});
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Source
|
|
99
|
+
|
|
100
|
+
- `src/api.ts` — exported library surface
|
|
101
|
+
- `src/config.ts` — shared config loading and writing
|
|
102
|
+
- `src/config-settings.ts` — config-backed settings registration helper
|
|
103
|
+
- `src/settings-ui.ts` — shared settings overlay
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mrclrchtr/supi-core",
|
|
3
|
+
"version": "1.11.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-ai": "*",
|
|
23
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
24
|
+
"@earendil-works/pi-tui": "*",
|
|
25
|
+
"typebox": "*"
|
|
26
|
+
},
|
|
27
|
+
"peerDependenciesMeta": {
|
|
28
|
+
"@earendil-works/pi-ai": {
|
|
29
|
+
"optional": true
|
|
30
|
+
},
|
|
31
|
+
"@earendil-works/pi-coding-agent": {
|
|
32
|
+
"optional": true
|
|
33
|
+
},
|
|
34
|
+
"@earendil-works/pi-tui": {
|
|
35
|
+
"optional": true
|
|
36
|
+
},
|
|
37
|
+
"typebox": {
|
|
38
|
+
"optional": true
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"main": "src/api.ts",
|
|
42
|
+
"exports": {
|
|
43
|
+
"./api": "./src/api.ts",
|
|
44
|
+
"./config": "./src/config.ts",
|
|
45
|
+
"./context": "./src/context.ts",
|
|
46
|
+
"./debug": "./src/debug-registry.ts",
|
|
47
|
+
"./llm": "./src/llm.ts",
|
|
48
|
+
"./package.json": "./package.json",
|
|
49
|
+
"./path": "./src/path.ts",
|
|
50
|
+
"./progress-widget": "./src/progress-widget.ts",
|
|
51
|
+
"./project": "./src/project.ts",
|
|
52
|
+
"./session": "./src/session.ts",
|
|
53
|
+
"./settings": "./src/settings.ts",
|
|
54
|
+
"./settings-ui": "./src/settings-ui.ts",
|
|
55
|
+
"./terminal": "./src/terminal.ts",
|
|
56
|
+
"./tool-framework": "./src/tool-framework.ts",
|
|
57
|
+
"./types": "./src/types.ts"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// supi-core — shared infrastructure for SuPi extensions.
|
|
2
|
+
// Provides XML context tag wrapping, unified config system, context-message utilities,
|
|
3
|
+
// settings registry for supi-wide TUI settings, and a shared tool-spec/registration framework.
|
|
4
|
+
//
|
|
5
|
+
// Convenience barrel — re-exports all domain entry points.
|
|
6
|
+
// For lighter imports, use one of the domain subpaths directly
|
|
7
|
+
// (e.g. @mrclrchtr/supi-core/config, @mrclrchtr/supi-core/context).
|
|
8
|
+
|
|
9
|
+
// biome-ignore lint/performance/noReExportAll: intentional convenience barrel
|
|
10
|
+
export * from "./config.ts";
|
|
11
|
+
// biome-ignore lint/performance/noReExportAll: intentional convenience barrel
|
|
12
|
+
export * from "./context.ts";
|
|
13
|
+
// biome-ignore lint/performance/noReExportAll: intentional convenience barrel
|
|
14
|
+
export * from "./debug-registry.ts";
|
|
15
|
+
// biome-ignore lint/performance/noReExportAll: intentional convenience barrel
|
|
16
|
+
export * from "./llm.ts";
|
|
17
|
+
// biome-ignore lint/performance/noReExportAll: intentional convenience barrel
|
|
18
|
+
export * from "./path.ts";
|
|
19
|
+
// biome-ignore lint/performance/noReExportAll: intentional convenience barrel
|
|
20
|
+
export * from "./project.ts";
|
|
21
|
+
// biome-ignore lint/performance/noReExportAll: intentional convenience barrel
|
|
22
|
+
export * from "./session.ts";
|
|
23
|
+
// biome-ignore lint/performance/noReExportAll: intentional convenience barrel
|
|
24
|
+
export * from "./settings.ts";
|
|
25
|
+
// biome-ignore lint/performance/noReExportAll: intentional convenience barrel
|
|
26
|
+
export * from "./settings-ui.ts";
|
|
27
|
+
// biome-ignore lint/performance/noReExportAll: intentional convenience barrel
|
|
28
|
+
export * from "./terminal.ts";
|
|
29
|
+
// biome-ignore lint/performance/noReExportAll: intentional convenience barrel
|
|
30
|
+
export * from "./tool-framework.ts";
|
|
31
|
+
// biome-ignore lint/performance/noReExportAll: intentional convenience barrel
|
|
32
|
+
export * from "./types.ts";
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Config-aware settings helper for SuPi config-backed settings sections.
|
|
2
|
+
// Wraps registerSettings() and centralizes selected-scope loading + scoped persistence.
|
|
3
|
+
//
|
|
4
|
+
// Setting items can declare a `configType` ("boolean" | "number" | "stringList")
|
|
5
|
+
// to enable auto-generated persistChange. When all items have a configType,
|
|
6
|
+
// the persistChange callback can be omitted.
|
|
7
|
+
|
|
8
|
+
import type { SettingItem } from "@earendil-works/pi-tui";
|
|
9
|
+
import type { SettingsScope } from "../settings/settings-registry.ts";
|
|
10
|
+
import { registerSettings } from "../settings/settings-registry.ts";
|
|
11
|
+
import { loadSupiConfigForScope, removeSupiConfigKey, writeSupiConfig } from "./config.ts";
|
|
12
|
+
|
|
13
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Supported config value types for declarative persistChange.
|
|
17
|
+
*
|
|
18
|
+
* - `"boolean"`: maps "on" → true, "off" → false
|
|
19
|
+
* - `"number"`: parses integer via Number.parseInt, falls back to unset on invalid
|
|
20
|
+
* - `"stringList"`: splits on comma, trims whitespace, unsets on empty
|
|
21
|
+
*/
|
|
22
|
+
export type ConfigSettingType = "boolean" | "number" | "stringList";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Extended setting item that can declare its config type for auto-generated
|
|
26
|
+
* persistence handling.
|
|
27
|
+
*/
|
|
28
|
+
export interface ConfigSettingItem extends SettingItem {
|
|
29
|
+
/**
|
|
30
|
+
* When set, persistChange for this item is auto-generated.
|
|
31
|
+
* All items must declare a configType for auto-generation to activate.
|
|
32
|
+
*/
|
|
33
|
+
configType?: ConfigSettingType;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Helpers provided to the persistChange callback for writing or removing
|
|
38
|
+
* scoped config values.
|
|
39
|
+
*/
|
|
40
|
+
export interface ConfigSettingsHelpers {
|
|
41
|
+
/** Write a key to the selected scope's config section. */
|
|
42
|
+
set(key: string, value: unknown): void;
|
|
43
|
+
/** Remove a key from the selected scope's config section. */
|
|
44
|
+
unset(key: string): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ConfigSettingsOptions<T> {
|
|
48
|
+
/** Extension identifier — e.g. "lsp", "claude-md" */
|
|
49
|
+
id: string;
|
|
50
|
+
/** Human-readable label shown in the UI */
|
|
51
|
+
label: string;
|
|
52
|
+
/** SuPi config section name — e.g. "lsp", "claude-md" */
|
|
53
|
+
section: string;
|
|
54
|
+
/** Default config values */
|
|
55
|
+
defaults: T;
|
|
56
|
+
/**
|
|
57
|
+
* Build SettingItem[] from scoped config. Called by loadValues.
|
|
58
|
+
*
|
|
59
|
+
* Items can include a `configType` property for auto-generated
|
|
60
|
+
* persistChange handling. When ALL items declare a configType,
|
|
61
|
+
* the `persistChange` callback can be omitted.
|
|
62
|
+
*/
|
|
63
|
+
buildItems: (settings: T, scope: SettingsScope, cwd: string) => ConfigSettingItem[];
|
|
64
|
+
/**
|
|
65
|
+
* Handle a settings change with scoped persistence helpers.
|
|
66
|
+
*
|
|
67
|
+
* Optional when all items returned by `buildItems` declare a `configType`.
|
|
68
|
+
* Required when any item lacks a `configType`.
|
|
69
|
+
*/
|
|
70
|
+
persistChange?: (
|
|
71
|
+
scope: SettingsScope,
|
|
72
|
+
cwd: string,
|
|
73
|
+
settingId: string,
|
|
74
|
+
value: string,
|
|
75
|
+
helpers: ConfigSettingsHelpers,
|
|
76
|
+
) => void;
|
|
77
|
+
/** Optional home directory for config resolution (testing). */
|
|
78
|
+
homeDir?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Auto-generated persistChange ───────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
function autoPersistChange(
|
|
84
|
+
settingId: string,
|
|
85
|
+
value: string,
|
|
86
|
+
helpers: ConfigSettingsHelpers,
|
|
87
|
+
items: ConfigSettingItem[],
|
|
88
|
+
): void {
|
|
89
|
+
const item = items.find((i) => i.id === settingId);
|
|
90
|
+
if (!item?.configType) return;
|
|
91
|
+
|
|
92
|
+
switch (item.configType) {
|
|
93
|
+
case "boolean": {
|
|
94
|
+
helpers.set(settingId, value === "on");
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
case "number": {
|
|
98
|
+
const num = Number.parseInt(value, 10);
|
|
99
|
+
if (Number.isFinite(num) && num > 0) {
|
|
100
|
+
helpers.set(settingId, num);
|
|
101
|
+
} else {
|
|
102
|
+
helpers.unset(settingId);
|
|
103
|
+
}
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
case "stringList": {
|
|
107
|
+
const names = value
|
|
108
|
+
.split(",")
|
|
109
|
+
.map((s) => s.trim())
|
|
110
|
+
.filter((s) => s.length > 0);
|
|
111
|
+
if (names.length > 0) {
|
|
112
|
+
helpers.set(settingId, names);
|
|
113
|
+
} else {
|
|
114
|
+
helpers.unset(settingId);
|
|
115
|
+
}
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function areAllItemsDeclarative(items: ConfigSettingItem[]): boolean {
|
|
122
|
+
return items.length > 0 && items.every((i) => i.configType !== undefined);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Registration ───────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Register a config-backed settings section.
|
|
129
|
+
*
|
|
130
|
+
* Loads display values from the selected scope only (`defaults <- selected scope`)
|
|
131
|
+
* instead of merged effective runtime config. Provides scoped `set` / `unset`
|
|
132
|
+
* persistence helpers so extensions don't need to wire `writeSupiConfig` /
|
|
133
|
+
* `removeSupiConfigKey` by hand.
|
|
134
|
+
*
|
|
135
|
+
* When every item returned by `buildItems` declares a `configType`, the
|
|
136
|
+
* `persistChange` callback is optional and will be auto-generated.
|
|
137
|
+
*/
|
|
138
|
+
export function registerConfigSettings<T>(options: ConfigSettingsOptions<T>): void {
|
|
139
|
+
let cachedItems: ConfigSettingItem[] | undefined;
|
|
140
|
+
|
|
141
|
+
registerSettings({
|
|
142
|
+
id: options.id,
|
|
143
|
+
label: options.label,
|
|
144
|
+
loadValues: (scope, cwd) => {
|
|
145
|
+
const settings = loadSupiConfigForScope(options.section, cwd, options.defaults, {
|
|
146
|
+
scope,
|
|
147
|
+
homeDir: options.homeDir,
|
|
148
|
+
});
|
|
149
|
+
const items = options.buildItems(settings, scope, cwd);
|
|
150
|
+
cachedItems = items;
|
|
151
|
+
return items;
|
|
152
|
+
},
|
|
153
|
+
persistChange: (scope, cwd, settingId, value) => {
|
|
154
|
+
const helpers: ConfigSettingsHelpers = {
|
|
155
|
+
set: (key, val) => {
|
|
156
|
+
writeSupiConfig(
|
|
157
|
+
{ section: options.section, scope, cwd },
|
|
158
|
+
{ [key]: val },
|
|
159
|
+
{ homeDir: options.homeDir },
|
|
160
|
+
);
|
|
161
|
+
},
|
|
162
|
+
unset: (key) => {
|
|
163
|
+
removeSupiConfigKey({ section: options.section, scope, cwd }, key, {
|
|
164
|
+
homeDir: options.homeDir,
|
|
165
|
+
});
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// Use manual persistChange when provided
|
|
170
|
+
if (options.persistChange) {
|
|
171
|
+
options.persistChange(scope, cwd, settingId, value, helpers);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Auto-generate when all items are declarative
|
|
176
|
+
const items = cachedItems ?? options.buildItems(options.defaults, scope, cwd);
|
|
177
|
+
if (areAllItemsDeclarative(items)) {
|
|
178
|
+
autoPersistChange(settingId, value, helpers, items);
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
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
|
+
export 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
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Shorthand for {@link loadSupiConfig} that infers the return type from defaults.
|
|
190
|
+
*
|
|
191
|
+
* Reduces boilerplate when a package only needs the merged runtime config.
|
|
192
|
+
*
|
|
193
|
+
* @example
|
|
194
|
+
* ```ts
|
|
195
|
+
* const config = loadSectionConfig("my-ext", cwd, { enabled: true, timeout: 30 });
|
|
196
|
+
* // config is typed as { enabled: boolean; timeout: number }
|
|
197
|
+
* ```
|
|
198
|
+
*/
|
|
199
|
+
export function loadSectionConfig<T extends Record<string, unknown>>(
|
|
200
|
+
section: string,
|
|
201
|
+
cwd: string,
|
|
202
|
+
defaults: T,
|
|
203
|
+
options?: SupiConfigOptions,
|
|
204
|
+
): T {
|
|
205
|
+
return loadSupiConfig(section, cwd, defaults, options);
|
|
206
|
+
}
|