pi-soly 0.2.1

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/config.ts ADDED
@@ -0,0 +1,228 @@
1
+ // =============================================================================
2
+ // config.ts — soly config loader (per-project + global)
3
+ // =============================================================================
4
+ //
5
+ // Per-project: `.soly/config.json` (version-controlled, repo-specific).
6
+ // Global: `~/.soly/config.json` (per-user, applies to all projects).
7
+ //
8
+ // Lookup order: per-project overrides global overrides defaults. Missing
9
+ // files are silently ignored (defaults apply). Malformed JSON returns a
10
+ // "fallback to defaults" warning via the return value's `warnings` array.
11
+ //
12
+ // Schema versioning: if the file's `version` doesn't match SOLY_CONFIG_VERSION,
13
+ // we still try to merge what we can and add a warning.
14
+ // =============================================================================
15
+
16
+ import * as fs from "node:fs";
17
+ import * as os from "node:os";
18
+ import * as path from "node:path";
19
+
20
+ export const SOLY_CONFIG_VERSION = 1 as const;
21
+
22
+ /** Defaults — also the "schema" (all keys optional, merged with user overrides). */
23
+ export interface SolyConfig {
24
+ version: typeof SOLY_CONFIG_VERSION;
25
+ iteration: {
26
+ /** Auto-prune iteration files older than N days on session_start. 0 = keep forever. */
27
+ retentionDays: number;
28
+ /** Include RESEARCH.md sections in the per-iteration context bundle. */
29
+ includeResearch: boolean;
30
+ /** Include Anti-Patterns table from .continue-here.md in the bundle. */
31
+ includeAntiPatterns: boolean;
32
+ };
33
+ agent: {
34
+ /** When ask_pro tool is available, prefer it over soly_ask_user in discuss flow. */
35
+ preferAskPro: boolean;
36
+ /** When soly pause is invoked, also auto-save HANDOFF.json (currently always true; knob for future). */
37
+ autoCheckpointOnPause: boolean;
38
+ /** Opt-in: install soly-worker / soly-oracle agent configs to
39
+ * ~/.pi/agent/agents/ on session_start, and use soly-worker in
40
+ * soly execute workflows. Off by default — most users don't need
41
+ * soly-specialized subagents since the workflow template already
42
+ * contains soly instructions. */
43
+ useSolyWorkerSubagents: boolean;
44
+ };
45
+ display: {
46
+ /** Always show the recommended (⭐) option as the first row. */
47
+ defaultRecommendedFirst: boolean;
48
+ /** Cap on how many phases appear in /soly status / soly status. */
49
+ maxPhasesInStatus: number;
50
+ /** Cap on how many decisions appear in soly log default. */
51
+ maxDecisionsInLog: number;
52
+ };
53
+ paths: {
54
+ /** Globs to exclude from code-map / project scans. */
55
+ excludeGlobs: string[];
56
+ };
57
+ hotReload: {
58
+ /** Hot-reload poll interval (ms). Default 2000. */
59
+ pollMs: number;
60
+ /** Show a notify when rules change. */
61
+ notifyOnRuleChange: boolean;
62
+ };
63
+ editor: {
64
+ /** $EDITOR command for opening files (e.g. "code", "vim", "cursor"). */
65
+ command: string;
66
+ };
67
+ }
68
+
69
+ export const DEFAULT_CONFIG: SolyConfig = {
70
+ version: SOLY_CONFIG_VERSION,
71
+ iteration: {
72
+ retentionDays: 0, // 0 = keep forever
73
+ includeResearch: true,
74
+ includeAntiPatterns: true,
75
+ },
76
+ agent: {
77
+ preferAskPro: true,
78
+ autoCheckpointOnPause: true,
79
+ useSolyWorkerSubagents: false,
80
+ },
81
+ display: {
82
+ defaultRecommendedFirst: true,
83
+ maxPhasesInStatus: 20,
84
+ maxDecisionsInLog: 20,
85
+ },
86
+ paths: {
87
+ excludeGlobs: ["**/node_modules/**", "**/dist/**", "**/.git/**", "**/build/**", "**/.next/**", "**/.nuxt/**", "**/coverage/**"],
88
+ },
89
+ hotReload: {
90
+ pollMs: 2000,
91
+ notifyOnRuleChange: true,
92
+ },
93
+ editor: {
94
+ command: "code",
95
+ },
96
+ };
97
+
98
+ /** Raw parsed shape from a config.json — could be partial, could have wrong types. */
99
+ type RawConfig = Partial<SolyConfig> & { version?: number };
100
+
101
+ function readJsonIfExists(path: string): RawConfig | null {
102
+ try {
103
+ if (!fs.existsSync(path)) return null;
104
+ const raw = fs.readFileSync(path, "utf-8");
105
+ return JSON.parse(raw) as RawConfig;
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ export interface LoadConfigResult {
112
+ config: SolyConfig;
113
+ warnings: string[];
114
+ sources: { global: string | null; project: string | null };
115
+ }
116
+
117
+ /** Deep-merge `over` into `base`, with the same shape. Used to apply user
118
+ * overrides on top of defaults. Only known keys are merged. */
119
+ function deepMerge(base: SolyConfig, over: RawConfig): SolyConfig {
120
+ const merged: SolyConfig = JSON.parse(JSON.stringify(base));
121
+ if (over.iteration) {
122
+ if (typeof over.iteration.retentionDays === "number")
123
+ merged.iteration.retentionDays = over.iteration.retentionDays;
124
+ if (typeof over.iteration.includeResearch === "boolean")
125
+ merged.iteration.includeResearch = over.iteration.includeResearch;
126
+ if (typeof over.iteration.includeAntiPatterns === "boolean")
127
+ merged.iteration.includeAntiPatterns = over.iteration.includeAntiPatterns;
128
+ }
129
+ if (over.agent) {
130
+ if (typeof over.agent.preferAskPro === "boolean")
131
+ merged.agent.preferAskPro = over.agent.preferAskPro;
132
+ if (typeof over.agent.autoCheckpointOnPause === "boolean")
133
+ merged.agent.autoCheckpointOnPause = over.agent.autoCheckpointOnPause;
134
+ if (typeof over.agent.useSolyWorkerSubagents === "boolean")
135
+ merged.agent.useSolyWorkerSubagents = over.agent.useSolyWorkerSubagents;
136
+ }
137
+ if (over.display) {
138
+ if (typeof over.display.defaultRecommendedFirst === "boolean")
139
+ merged.display.defaultRecommendedFirst = over.display.defaultRecommendedFirst;
140
+ if (typeof over.display.maxPhasesInStatus === "number")
141
+ merged.display.maxPhasesInStatus = over.display.maxPhasesInStatus;
142
+ if (typeof over.display.maxDecisionsInLog === "number")
143
+ merged.display.maxDecisionsInLog = over.display.maxDecisionsInLog;
144
+ }
145
+ if (over.paths && Array.isArray(over.paths.excludeGlobs)) {
146
+ merged.paths.excludeGlobs = over.paths.excludeGlobs.filter((g) => typeof g === "string");
147
+ }
148
+ if (over.hotReload) {
149
+ if (typeof over.hotReload.pollMs === "number")
150
+ merged.hotReload.pollMs = over.hotReload.pollMs;
151
+ if (typeof over.hotReload.notifyOnRuleChange === "boolean")
152
+ merged.hotReload.notifyOnRuleChange = over.hotReload.notifyOnRuleChange;
153
+ }
154
+ if (over.editor && typeof over.editor.command === "string") {
155
+ merged.editor.command = over.editor.command;
156
+ }
157
+ return merged;
158
+ }
159
+
160
+ /** Load soly config. Per-project overrides global overrides defaults.
161
+ * Returns merged config + warnings about any issues.
162
+ * Optional `homeDir` overrides `os.homedir()` — used by tests; production
163
+ * callers leave it unset. */
164
+ export function loadConfig(cwd: string, homeDir?: string): LoadConfigResult {
165
+ const warnings: string[] = [];
166
+ const sources = { global: null as string | null, project: null as string | null };
167
+
168
+ const home = homeDir ?? os.homedir();
169
+ const globalPath = path.join(home, ".soly", "config.json");
170
+ const globalRaw = readJsonIfExists(globalPath);
171
+ if (globalRaw) {
172
+ sources.global = globalPath;
173
+ if (typeof globalRaw.version === "number" && globalRaw.version !== SOLY_CONFIG_VERSION) {
174
+ warnings.push(
175
+ `global config at ${globalPath} has version ${globalRaw.version}, expected ${SOLY_CONFIG_VERSION} — using defaults for unknown fields`,
176
+ );
177
+ }
178
+ }
179
+ const projectSolyDir = path.join(cwd, ".soly");
180
+ const projectPath = path.join(projectSolyDir, "config.json");
181
+ const projectRaw = readJsonIfExists(projectPath);
182
+ if (projectRaw) {
183
+ sources.project = projectPath;
184
+ if (typeof projectRaw.version === "number" && projectRaw.version !== SOLY_CONFIG_VERSION) {
185
+ warnings.push(
186
+ `project config at ${projectPath} has version ${projectRaw.version}, expected ${SOLY_CONFIG_VERSION} — using defaults for unknown fields`,
187
+ );
188
+ }
189
+ }
190
+
191
+ let config = deepMerge(DEFAULT_CONFIG, globalRaw ?? {});
192
+ config = deepMerge(config, projectRaw ?? {});
193
+ return { config, warnings, sources };
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Iteration retention
198
+ // ---------------------------------------------------------------------------
199
+
200
+ /** Delete iteration files older than `retentionDays` (0 = no deletion). */
201
+ export function pruneOldIterations(solyDir: string, retentionDays: number): {
202
+ pruned: number;
203
+ kept: number;
204
+ } {
205
+ if (retentionDays <= 0) return { pruned: 0, kept: -1 };
206
+ const iterDir = path.join(solyDir, "iterations");
207
+ if (!fs.existsSync(iterDir)) return { pruned: 0, kept: 0 };
208
+
209
+ const now = Date.now();
210
+ const cutoffMs = retentionDays * 24 * 60 * 60 * 1000;
211
+ let pruned = 0;
212
+ let kept = 0;
213
+ for (const entry of fs.readdirSync(iterDir)) {
214
+ const full = path.join(iterDir, entry);
215
+ try {
216
+ const stat = fs.statSync(full);
217
+ if (stat.isFile() && now - stat.mtimeMs > cutoffMs) {
218
+ fs.unlinkSync(full);
219
+ pruned++;
220
+ } else {
221
+ kept++;
222
+ }
223
+ } catch {
224
+ // skip unreadable
225
+ }
226
+ }
227
+ return { pruned, kept };
228
+ }