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/README.md +372 -0
- package/agents/soly-debugger.md +60 -0
- package/agents/soly-documenter.md +82 -0
- package/agents/soly-oracle.md +69 -0
- package/agents/soly-refactor.md +65 -0
- package/agents/soly-reviewer.md +107 -0
- package/agents/soly-tester.md +56 -0
- package/agents/soly-worker.md +84 -0
- package/agents-install.ts +105 -0
- package/commands.ts +778 -0
- package/config.ts +228 -0
- package/core.ts +1599 -0
- package/docs.ts +235 -0
- package/env.ts +196 -0
- package/git.ts +95 -0
- package/html.ts +157 -0
- package/index.ts +718 -0
- package/integrations.ts +64 -0
- package/intent.ts +303 -0
- package/iteration.ts +712 -0
- package/nudge.ts +123 -0
- package/package.json +66 -0
- package/scratchpad.ts +117 -0
- package/tools.ts +1132 -0
- package/workflows/execute.ts +401 -0
- package/workflows/index.ts +235 -0
- package/workflows/inspect.ts +492 -0
- package/workflows/parser.ts +268 -0
- package/workflows/pause.ts +150 -0
- package/workflows/planning.ts +624 -0
- package/workflows/quick.ts +258 -0
- package/workflows/resume.ts +201 -0
- package/workflows-data/discuss-phase.md +292 -0
- package/workflows-data/execute-phase.md +200 -0
- package/workflows-data/execute-plan.md +251 -0
- package/workflows-data/execute-task.md +116 -0
- package/workflows-data/pause-work.md +142 -0
- package/workflows-data/plan-phase.md +199 -0
- package/workflows-data/plan-task.md +185 -0
package/index.ts
ADDED
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// index.ts — Main soly extension entry point
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Loads .soly/rules/ and .soly/ project state into the agent's system
|
|
6
|
+
// prompt, and registers:
|
|
7
|
+
// - slash commands /rules /soly /rulewizard /why
|
|
8
|
+
// - LLM tools soly_read soly_log_decision soly_list_phases
|
|
9
|
+
// - input hooks nudge (soft UI hint) + workflow verbs ("soly ...")
|
|
10
|
+
//
|
|
11
|
+
// All heavy logic lives in submodules:
|
|
12
|
+
// - core.ts data types, loaders, builders
|
|
13
|
+
// - nudge.ts behavioral nudge (pre-action gate + subagent preference)
|
|
14
|
+
// - commands.ts /rules /soly /rulewizard /why
|
|
15
|
+
// - tools.ts soly_read soly_log_decision soly_list_phases
|
|
16
|
+
// - workflows/ soly execute / pause / compact (plain-text input only)
|
|
17
|
+
//
|
|
18
|
+
// To add a new workflow verb: edit workflows/parser.ts + workflows/<verb>.ts,
|
|
19
|
+
// then re-export the handler in workflows/index.ts. No changes needed here.
|
|
20
|
+
// =============================================================================
|
|
21
|
+
|
|
22
|
+
import * as os from "node:os";
|
|
23
|
+
import * as path from "node:path";
|
|
24
|
+
import * as fs from "node:fs";
|
|
25
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
analyzeRules,
|
|
29
|
+
buildProjectStateSection,
|
|
30
|
+
buildRulesSection,
|
|
31
|
+
buildStatusLine,
|
|
32
|
+
CONTEXT_WINDOW_TOKENS,
|
|
33
|
+
DEFAULT_PROGRESS,
|
|
34
|
+
extractFilePathsFromPrompt,
|
|
35
|
+
formatTok,
|
|
36
|
+
loadAllRules,
|
|
37
|
+
loadPhaseRules,
|
|
38
|
+
loadProjectState,
|
|
39
|
+
STATUS_ID,
|
|
40
|
+
solyDirFor,
|
|
41
|
+
buildNextHint,
|
|
42
|
+
buildDriftReminder,
|
|
43
|
+
type RuleFile,
|
|
44
|
+
type SolyState,
|
|
45
|
+
type SourceSpec,
|
|
46
|
+
} from "./core.js";
|
|
47
|
+
import { buildIntegrationsSection } from "./integrations.js";
|
|
48
|
+
import { installSolyAgents } from "./agents-install.js";
|
|
49
|
+
import {
|
|
50
|
+
DEFAULT_CONFIG,
|
|
51
|
+
loadConfig,
|
|
52
|
+
pruneOldIterations,
|
|
53
|
+
type SolyConfig,
|
|
54
|
+
} from "./config.js";
|
|
55
|
+
import { classifyTaskHeuristics, buildNudgeSection } from "./nudge.js";
|
|
56
|
+
import { registerCommands, type CommandUI } from "./commands.js";
|
|
57
|
+
import { registerTools } from "./tools.js";
|
|
58
|
+
import { registerWorkflows } from "./workflows/index.js";
|
|
59
|
+
import { readGitContext, buildGitSection, type GitContext } from "./git.js";
|
|
60
|
+
import { startHotReload, type HotReloadHandle } from "./hotreload.js";
|
|
61
|
+
import { detectEnv, buildEnvSection, type EnvSummary } from "./env.js";
|
|
62
|
+
import { buildCodeMap, buildCodeMapSection, type CodeMap } from "./codemap.js";
|
|
63
|
+
import { loadIntentDocs, buildIntentSection, loadInlineIntentBodies, type IntentDoc } from "./intent.js";
|
|
64
|
+
|
|
65
|
+
export default function solyExtension(pi: ExtensionAPI) {
|
|
66
|
+
// ============================================================================
|
|
67
|
+
// State (module-local, lives for the duration of one extension instance)
|
|
68
|
+
// ============================================================================
|
|
69
|
+
|
|
70
|
+
// Rules
|
|
71
|
+
let rules: RuleFile[] = [];
|
|
72
|
+
let rulesLoaded: string[] = [];
|
|
73
|
+
let lastRulesTokens = 0;
|
|
74
|
+
let ruleSources: SourceSpec[] = [];
|
|
75
|
+
let overriddenRulePaths: string[] = [];
|
|
76
|
+
let sessionCwd = "";
|
|
77
|
+
|
|
78
|
+
// ============================================================================
|
|
79
|
+
// Agent switcher (Shift+Tab cycles through available subagents)
|
|
80
|
+
// ============================================================================
|
|
81
|
+
|
|
82
|
+
// ============================================================================
|
|
83
|
+
// Agent switcher: REMOVED. The agent cycler is now owned by the
|
|
84
|
+
// separate `pi-switch` extension (header bar + Ctrl+Shift+S + /agent slash).
|
|
85
|
+
// Soly still owns the soly-specific agent files (soly-worker.md etc.) and
|
|
86
|
+
// the auto-install on opt-in. Workflows read the current agent from
|
|
87
|
+
// globalThis.__PI_SWITCH_AGENT__ (set by pi-switch).
|
|
88
|
+
// ============================================================================
|
|
89
|
+
|
|
90
|
+
// Config (per-project + global + defaults). Refreshed on session_start
|
|
91
|
+
// and on each session_start (the LLM can call /soly config to view).
|
|
92
|
+
let activeConfig: SolyConfig = DEFAULT_CONFIG;
|
|
93
|
+
const getActiveConfig = (): SolyConfig => activeConfig;
|
|
94
|
+
|
|
95
|
+
// Drift counter — tracks how many non-soly turns the user has spent
|
|
96
|
+
// before invoking a soly verb. After the threshold, a reminder is
|
|
97
|
+
// injected into the next system prompt so the LLM can suggest a sync
|
|
98
|
+
// (status, pause, etc.). Resets on every parsed soly verb.
|
|
99
|
+
let solyDrift = {
|
|
100
|
+
turnsSinceLastSolyVerb: 0,
|
|
101
|
+
lastReminderAt: 0,
|
|
102
|
+
REMINDER_THRESHOLD: 5,
|
|
103
|
+
};
|
|
104
|
+
function resetSolyDrift() {
|
|
105
|
+
solyDrift.turnsSinceLastSolyVerb = 0;
|
|
106
|
+
solyDrift.lastReminderAt = 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Project state
|
|
110
|
+
let state: SolyState = {
|
|
111
|
+
solyDir: "",
|
|
112
|
+
exists: false,
|
|
113
|
+
milestone: "—",
|
|
114
|
+
milestoneName: "",
|
|
115
|
+
status: "unknown",
|
|
116
|
+
lastUpdated: "",
|
|
117
|
+
progress: { ...DEFAULT_PROGRESS },
|
|
118
|
+
position: null,
|
|
119
|
+
currentPhase: null,
|
|
120
|
+
currentPlanPath: null,
|
|
121
|
+
stateBody: "",
|
|
122
|
+
roadmapBody: "",
|
|
123
|
+
phases: [],
|
|
124
|
+
features: [],
|
|
125
|
+
tasks: [],
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Status line cache (anti-flicker)
|
|
129
|
+
let lastStatusLine = "";
|
|
130
|
+
|
|
131
|
+
// Behavioral nudge state
|
|
132
|
+
let nudgeActiveForTask = false;
|
|
133
|
+
let lastNudgePromptKey = "";
|
|
134
|
+
|
|
135
|
+
// Git context (cached, refreshed on hot reload + before_agent_start)
|
|
136
|
+
let gitContext: GitContext = { available: false, branch: null, statusShort: null, lastCommits: [] };
|
|
137
|
+
let lastGitSection = "";
|
|
138
|
+
|
|
139
|
+
// Hot reload watcher for rules
|
|
140
|
+
let hotReload: HotReloadHandle | null = null;
|
|
141
|
+
|
|
142
|
+
// Session stats (computed on demand)
|
|
143
|
+
let sessionStats: { turns: number; tokensEstimate: number } = { turns: 0, tokensEstimate: 0 };
|
|
144
|
+
|
|
145
|
+
// Env summary (detected once at session_start, cheap to re-detect)
|
|
146
|
+
let envSummary: EnvSummary | null = null;
|
|
147
|
+
let lastEnvSection = "";
|
|
148
|
+
|
|
149
|
+
// Code map (built once at session_start)
|
|
150
|
+
let codeMap: CodeMap | null = null;
|
|
151
|
+
let lastCodeMapSection = "";
|
|
152
|
+
|
|
153
|
+
// Project intent (zero-point docs from .soly/docs/) — always loaded
|
|
154
|
+
let intentDocs: IntentDoc[] = [];
|
|
155
|
+
let lastIntentSection = "";
|
|
156
|
+
|
|
157
|
+
// ============================================================================
|
|
158
|
+
// Loaders
|
|
159
|
+
// ============================================================================
|
|
160
|
+
|
|
161
|
+
const refreshRules = () => {
|
|
162
|
+
const result = loadAllRules(ruleSources);
|
|
163
|
+
alwaysOnRules = result.rules;
|
|
164
|
+
overriddenRulePaths = result.overridden;
|
|
165
|
+
// Also refresh phase rules — they may have changed
|
|
166
|
+
reloadPhaseRules();
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const refreshState = () => {
|
|
170
|
+
if (!state.solyDir) return;
|
|
171
|
+
state = loadProjectState(state.solyDir);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const refreshIntent = () => {
|
|
175
|
+
intentDocs = loadIntentDocs(sessionCwd, state.currentPhase?.number);
|
|
176
|
+
const { section } = buildIntentSection(intentDocs);
|
|
177
|
+
lastIntentSection = section;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// ============================================================================
|
|
181
|
+
// Phase rules + last-session mtime tracking
|
|
182
|
+
// ============================================================================
|
|
183
|
+
|
|
184
|
+
/** Always-on rules (no phase) — reloaded by refreshRules + hot reload. */
|
|
185
|
+
let alwaysOnRules: RuleFile[] = [];
|
|
186
|
+
/** Phase-scoped rules for the currently active phase. */
|
|
187
|
+
let phaseRules: RuleFile[] = [];
|
|
188
|
+
|
|
189
|
+
/** Combined view consumed by buildRulesSection / status. */
|
|
190
|
+
const combinedRules = (): RuleFile[] => [...alwaysOnRules, ...phaseRules];
|
|
191
|
+
|
|
192
|
+
/** Reload phase rules for the current state's currentPhase. */
|
|
193
|
+
const reloadPhaseRules = () => {
|
|
194
|
+
const phase = state.currentPhase;
|
|
195
|
+
if (!phase) {
|
|
196
|
+
phaseRules = [];
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
phaseRules = loadPhaseRules(phase.dir, phase.number);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Persistent storage of rule mtimes from the previous session, so we can
|
|
204
|
+
* show a "rules changed since last session" diff at startup.
|
|
205
|
+
* Stored in <solyDir>/.soly-rule-mtimes.json (project) or
|
|
206
|
+
* <homedir>/.soly/rule-mtimes.json (global fallback).
|
|
207
|
+
*/
|
|
208
|
+
let lastSessionMtimes: Record<string, number> = {};
|
|
209
|
+
const mtimeStorePath = (): string => {
|
|
210
|
+
const base = state.solyDir && fs.existsSync(state.solyDir)
|
|
211
|
+
? state.solyDir
|
|
212
|
+
: path.join(os.homedir(), ".soly");
|
|
213
|
+
try {
|
|
214
|
+
fs.mkdirSync(base, { recursive: true });
|
|
215
|
+
} catch {}
|
|
216
|
+
return path.join(base, "rule-mtimes.json");
|
|
217
|
+
};
|
|
218
|
+
const captureLastSessionRuleMtimes = () => {
|
|
219
|
+
const filePath = mtimeStorePath();
|
|
220
|
+
try {
|
|
221
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
222
|
+
lastSessionMtimes = JSON.parse(raw);
|
|
223
|
+
} catch {
|
|
224
|
+
lastSessionMtimes = {};
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
const persistRuleMtimes = () => {
|
|
228
|
+
const mtimes: Record<string, number> = {};
|
|
229
|
+
for (const r of alwaysOnRules) mtimes[`${r.source}::${r.absPath}`] = r.mtimeMs;
|
|
230
|
+
try {
|
|
231
|
+
fs.writeFileSync(mtimeStorePath(), JSON.stringify(mtimes, null, 2));
|
|
232
|
+
} catch {
|
|
233
|
+
// best effort
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// ============================================================================
|
|
238
|
+
// Status
|
|
239
|
+
// ============================================================================
|
|
240
|
+
|
|
241
|
+
const updateStatus = (ui: CommandUI | { ui: { setStatus: (id: string, text: string | undefined) => void } }) => {
|
|
242
|
+
const setStatus = (ui as { ui: { setStatus: (id: string, text: string | undefined) => void } }).ui.setStatus;
|
|
243
|
+
const line = buildStatusLine(
|
|
244
|
+
combinedRules().length,
|
|
245
|
+
rulesLoaded.length,
|
|
246
|
+
lastRulesTokens,
|
|
247
|
+
state,
|
|
248
|
+
);
|
|
249
|
+
// Append session stats if non-zero (cheap; one short group)
|
|
250
|
+
const sessionGroup =
|
|
251
|
+
sessionStats.turns > 0
|
|
252
|
+
? `${"\x1b[2m"}session ${sessionStats.turns}t${sessionStats.tokensEstimate > 0 ? ` ${formatTok(sessionStats.tokensEstimate)}` : ""}${"\x1b[0m"}`
|
|
253
|
+
: "";
|
|
254
|
+
// Smart "next:" hint from project state (e.g. "→ next: soly execute 10")
|
|
255
|
+
const hint = buildNextHint(state);
|
|
256
|
+
const hintGroup = hint ? `${"\x1b[2m"}${hint}${"\x1b[0m"}` : "";
|
|
257
|
+
|
|
258
|
+
// Agent badge — owned by pi-switch extension (header bar + status line).
|
|
259
|
+
// Soly doesn't render the agent badge itself.
|
|
260
|
+
const agentGroup = "";
|
|
261
|
+
|
|
262
|
+
// Cross-extension: show pi-todo progress if either .soly/todos.json
|
|
263
|
+
// (soly-integration mode) OR .pi-todos.json (standalone mode) exists.
|
|
264
|
+
// Cheap (one stat + one small JSON read); cached only for the
|
|
265
|
+
// lifetime of one updateStatus call.
|
|
266
|
+
let todoGroup = "";
|
|
267
|
+
if (state.exists) {
|
|
268
|
+
const todoCandidates = [
|
|
269
|
+
path.join(state.solyDir, "todos.json"),
|
|
270
|
+
path.join(state.solyDir, ".pi-todos.json"),
|
|
271
|
+
];
|
|
272
|
+
for (const todoFile of todoCandidates) {
|
|
273
|
+
try {
|
|
274
|
+
if (!fs.existsSync(todoFile)) continue;
|
|
275
|
+
const raw = fs.readFileSync(todoFile, "utf-8");
|
|
276
|
+
const parsed = JSON.parse(raw) as { todos?: Array<{ status: string; activeForm?: string }> };
|
|
277
|
+
if (!Array.isArray(parsed.todos) || parsed.todos.length === 0) continue;
|
|
278
|
+
const total = parsed.todos.length;
|
|
279
|
+
const done = parsed.todos.filter((t) => t.status === "completed").length;
|
|
280
|
+
const inProgress = parsed.todos.find((t) => t.status === "in_progress");
|
|
281
|
+
if (inProgress?.activeForm) {
|
|
282
|
+
todoGroup = `${"\x1b[2m"}todos ${done}/${total} \u22ef ${inProgress.activeForm}${"\x1b[0m"}`;
|
|
283
|
+
} else {
|
|
284
|
+
todoGroup = `${"\x1b[2m"}todos ${done}/${total}${"\x1b[0m"}`;
|
|
285
|
+
}
|
|
286
|
+
break; // first match wins
|
|
287
|
+
} catch {
|
|
288
|
+
/* corrupt file — silently skip; pi-todo will rewrite on next update */
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const groups = [line, sessionGroup, todoGroup, agentGroup, hintGroup].filter((g) => g.length > 0);
|
|
294
|
+
const fullLine = groups.join(" ");
|
|
295
|
+
if (fullLine !== lastStatusLine) {
|
|
296
|
+
setStatus(STATUS_ID, fullLine || undefined);
|
|
297
|
+
lastStatusLine = fullLine;
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
// ============================================================================
|
|
302
|
+
// Register sub-features
|
|
303
|
+
// ============================================================================
|
|
304
|
+
|
|
305
|
+
registerCommands(pi, {
|
|
306
|
+
getRules: () => combinedRules(),
|
|
307
|
+
getOverridden: () => overriddenRulePaths,
|
|
308
|
+
refreshRules: () => refreshRules(),
|
|
309
|
+
getState: () => state,
|
|
310
|
+
refreshState: () => refreshState(),
|
|
311
|
+
updateStatus: (ui) => updateStatus(ui),
|
|
312
|
+
getConfig: getActiveConfig,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
registerTools(pi, {
|
|
316
|
+
getState: () => state,
|
|
317
|
+
refreshState: () => refreshState(),
|
|
318
|
+
getConfig: getActiveConfig,
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// ============================================================================
|
|
322
|
+
// Agent switcher: Ctrl+Shift+A cycles through available subagents.
|
|
323
|
+
// (Shift+Tab is taken by pi's thinking-level cycler; Ctrl+Shift+A is unused
|
|
324
|
+
// and mnemonic for "A"gent.)
|
|
325
|
+
// ============================================================================
|
|
326
|
+
// Agent switcher REMOVED — moved to the separate `pi-switch` extension.
|
|
327
|
+
// Soly no longer owns Ctrl+Shift+S, the header bar, or /agent slash.
|
|
328
|
+
// The current agent is read by soly workflows from
|
|
329
|
+
// globalThis.__PI_SWITCH_AGENT__ (set by pi-switch), with a fallback
|
|
330
|
+
// to "worker" if pi-switch isn't installed.
|
|
331
|
+
// ============================================================================
|
|
332
|
+
|
|
333
|
+
registerWorkflows(pi, {
|
|
334
|
+
getState: () => state,
|
|
335
|
+
getInteractiveRules: () =>
|
|
336
|
+
combinedRules()
|
|
337
|
+
.filter((r) => r.interactiveOnly)
|
|
338
|
+
.map((r) => r.relPath),
|
|
339
|
+
getActiveTools: () => pi.getActiveTools(),
|
|
340
|
+
getConfig: getActiveConfig,
|
|
341
|
+
onWorkflowUsed: resetSolyDrift,
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// ============================================================================
|
|
345
|
+
// Events
|
|
346
|
+
// ============================================================================
|
|
347
|
+
|
|
348
|
+
pi.on("session_start", async (event, ctx) => {
|
|
349
|
+
// Rules sources (priority order, higher wins on relPath collision).
|
|
350
|
+
// Project rules always beat global rules. .soly/rules.local/ is
|
|
351
|
+
// gitignored — for personal overrides on top of the project's rules.
|
|
352
|
+
ruleSources = [
|
|
353
|
+
{ dir: path.join(ctx.cwd, ".soly", "rules.local"), source: "project-soly", sourceLabel: "local", priority: 5 },
|
|
354
|
+
{ dir: path.join(ctx.cwd, ".soly", "rules"), source: "project-soly", sourceLabel: "soly", priority: 4 },
|
|
355
|
+
{ dir: path.join(os.homedir(), ".soly", "rules"), source: "global-soly", sourceLabel: "soly", priority: 2 },
|
|
356
|
+
];
|
|
357
|
+
refreshRules();
|
|
358
|
+
|
|
359
|
+
// Project state — soly owns .soly/ at the project root
|
|
360
|
+
state.solyDir = solyDirFor(ctx.cwd);
|
|
361
|
+
refreshState();
|
|
362
|
+
|
|
363
|
+
// Config: per-project overrides global overrides defaults
|
|
364
|
+
const cfgResult = loadConfig(ctx.cwd);
|
|
365
|
+
activeConfig = cfgResult.config;
|
|
366
|
+
for (const w of cfgResult.warnings) {
|
|
367
|
+
ctx.ui.notify(`soly config: ${w}`, "warning");
|
|
368
|
+
}
|
|
369
|
+
if (cfgResult.sources.global || cfgResult.sources.project) {
|
|
370
|
+
const sources = [
|
|
371
|
+
cfgResult.sources.global ? `global: ${cfgResult.sources.global}` : null,
|
|
372
|
+
cfgResult.sources.project ? `project: ${cfgResult.sources.project}` : null,
|
|
373
|
+
].filter(Boolean).join(", ");
|
|
374
|
+
ctx.ui.notify(`soly config loaded (${sources})`, "info");
|
|
375
|
+
}
|
|
376
|
+
// Auto-prune old iteration files per retention config
|
|
377
|
+
if (state.exists) {
|
|
378
|
+
const r = pruneOldIterations(state.solyDir, activeConfig.iteration.retentionDays);
|
|
379
|
+
if (r.pruned > 0) {
|
|
380
|
+
ctx.ui.notify(
|
|
381
|
+
`soly: pruned ${r.pruned} old iteration file(s) (retention ${activeConfig.iteration.retentionDays}d)`,
|
|
382
|
+
"info",
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Auto-install soly-aware subagent configs (soly-worker, soly-oracle)
|
|
388
|
+
// to ~/.pi/agent/agents/ on first run. Opt-in via
|
|
389
|
+
// config `agent.useSolyWorkerSubagents` (default false). Idempotent —
|
|
390
|
+
// respects any existing user-customized copies.
|
|
391
|
+
if (activeConfig.agent.useSolyWorkerSubagents) {
|
|
392
|
+
const extRoot = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1"));
|
|
393
|
+
const installResult = installSolyAgents(extRoot);
|
|
394
|
+
if (installResult.installed.length > 0) {
|
|
395
|
+
ctx.ui.notify(
|
|
396
|
+
`soly: installed subagent configs (${installResult.installed.join(", ")}) — run \`/subagents-doctor\` to verify`,
|
|
397
|
+
"info",
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
for (const e of installResult.errors) {
|
|
401
|
+
ctx.ui.notify(`soly: agent install error — ${e}`, "warning");
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Phase-scoped rules: if a phase is currently active, load its
|
|
406
|
+
// per-phase rules on top of the always-on rule set.
|
|
407
|
+
reloadPhaseRules();
|
|
408
|
+
|
|
409
|
+
// Restore agent from .soly/agent (if present) — survives session restart
|
|
410
|
+
if (state.exists) {
|
|
411
|
+
const agentFile = path.join(state.solyDir, "agent");
|
|
412
|
+
try {
|
|
413
|
+
if (fs.existsSync(agentFile)) {
|
|
414
|
+
const raw = fs.readFileSync(agentFile, "utf-8").trim();
|
|
415
|
+
if (raw && /^[a-zA-Z0-9_-]{1,64}$/.test(raw)) {
|
|
416
|
+
(globalThis as { __PI_SWITCH_AGENT__?: string }).__PI_SWITCH_AGENT__ = raw;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
} catch { /* best-effort */ }
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Capture rule mtimes for the "rules changed since last session" diff
|
|
423
|
+
captureLastSessionRuleMtimes();
|
|
424
|
+
|
|
425
|
+
// Reset derived state
|
|
426
|
+
sessionCwd = ctx.cwd;
|
|
427
|
+
rulesLoaded = [];
|
|
428
|
+
lastRulesTokens = 0;
|
|
429
|
+
nudgeActiveForTask = false;
|
|
430
|
+
lastNudgePromptKey = "";
|
|
431
|
+
sessionStats = { turns: 0, tokensEstimate: 0 };
|
|
432
|
+
|
|
433
|
+
// Read git context (best-effort, silent on failure)
|
|
434
|
+
gitContext = await readGitContext(ctx.cwd);
|
|
435
|
+
lastGitSection = buildGitSection(gitContext);
|
|
436
|
+
|
|
437
|
+
// Detect project env (cheap; ~5 fs reads)
|
|
438
|
+
envSummary = detectEnv(ctx.cwd);
|
|
439
|
+
lastEnvSection = buildEnvSection(envSummary);
|
|
440
|
+
|
|
441
|
+
// Build code map (walk cwd once; cap at 2 levels deep)
|
|
442
|
+
try {
|
|
443
|
+
codeMap = buildCodeMap(ctx.cwd);
|
|
444
|
+
lastCodeMapSection = buildCodeMapSection(codeMap);
|
|
445
|
+
} catch {
|
|
446
|
+
codeMap = null;
|
|
447
|
+
lastCodeMapSection = "";
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Load project intent (zero-point docs from .soly/docs/) — always
|
|
451
|
+
refreshIntent();
|
|
452
|
+
|
|
453
|
+
// Start hot-reload watcher on rules dirs
|
|
454
|
+
if (hotReload) hotReload.stop();
|
|
455
|
+
hotReload = startHotReload(ruleSources, {
|
|
456
|
+
onChange: (reason) => {
|
|
457
|
+
refreshRules();
|
|
458
|
+
updateStatus({
|
|
459
|
+
ui: { setStatus: (id, text) => ctx.ui.setStatus(id, text) },
|
|
460
|
+
});
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
// Editors save in bursts (write to .tmp, rename, touch). Coalesce
|
|
464
|
+
// those rapid reload events into a single user-visible notify.
|
|
465
|
+
hotReload.setNotifyHandler((reason) => {
|
|
466
|
+
ctx.ui.notify(`soly: rules reloaded (${reason})`, "info");
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
// Notifications (one-shot at startup)
|
|
470
|
+
if (alwaysOnRules.length > 0) {
|
|
471
|
+
const bySource = alwaysOnRules.reduce<Record<string, number>>((acc, r) => {
|
|
472
|
+
acc[r.sourceLabel] = (acc[r.sourceLabel] ?? 0) + 1;
|
|
473
|
+
return acc;
|
|
474
|
+
}, {});
|
|
475
|
+
const breakdown = Object.entries(bySource)
|
|
476
|
+
.map(([k, v]) => `${v} ${k}`)
|
|
477
|
+
.join(" + ");
|
|
478
|
+
let summary = `soly rules: ${alwaysOnRules.length} (${breakdown})`;
|
|
479
|
+
if (phaseRules.length > 0) {
|
|
480
|
+
summary += ` + ${phaseRules.length} phase-${state.currentPhase?.number}`;
|
|
481
|
+
}
|
|
482
|
+
ctx.ui.notify(summary, "info");
|
|
483
|
+
|
|
484
|
+
if (overriddenRulePaths.length > 0) {
|
|
485
|
+
ctx.ui.notify(
|
|
486
|
+
`soly: ${overriddenRulePaths.length} rule(s) overridden by project (${overriddenRulePaths.join(", ")})`,
|
|
487
|
+
"info",
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Rules diff vs last session
|
|
492
|
+
const currentMtimes: Record<string, number> = {};
|
|
493
|
+
for (const r of alwaysOnRules) currentMtimes[`${r.source}::${r.absPath}`] = r.mtimeMs;
|
|
494
|
+
const lastKeys = new Set(Object.keys(lastSessionMtimes));
|
|
495
|
+
const currentKeys = new Set(Object.keys(currentMtimes));
|
|
496
|
+
const added = [...currentKeys].filter((k) => !lastKeys.has(k));
|
|
497
|
+
const removed = [...lastKeys].filter((k) => !currentKeys.has(k));
|
|
498
|
+
const changed: string[] = [];
|
|
499
|
+
for (const k of currentKeys) {
|
|
500
|
+
if (lastKeys.has(k) && lastSessionMtimes[k] !== currentMtimes[k]) {
|
|
501
|
+
changed.push(k);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
if (added.length || removed.length || changed.length) {
|
|
505
|
+
const parts: string[] = [];
|
|
506
|
+
if (added.length) parts.push(`+${added.length}`);
|
|
507
|
+
if (removed.length) parts.push(`-${removed.length}`);
|
|
508
|
+
if (changed.length) parts.push(`~${changed.length}`);
|
|
509
|
+
ctx.ui.notify(`soly: rules changed since last session (${parts.join(" ")})`, "info");
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Rule budget analytics
|
|
513
|
+
const analytics = analyzeRules(alwaysOnRules, CONTEXT_WINDOW_TOKENS);
|
|
514
|
+
if (analytics.contextBudgetPct > 5) {
|
|
515
|
+
ctx.ui.notify(
|
|
516
|
+
`soly: rules use ${analytics.contextBudgetPct.toFixed(1)}% of context window (${formatTok(analytics.totalTokens)} across ${analytics.fileCount} files)`,
|
|
517
|
+
"info",
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
} else {
|
|
521
|
+
ctx.ui.notify("soly rules: none found in .soly/rules.local, .soly/rules, or ~/.soly/rules", "info");
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (state.exists) {
|
|
525
|
+
ctx.ui.notify(`soly state: ${state.milestone} (${state.phases.length} phases)`, "info");
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
updateStatus(ctx);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
pi.on("session_shutdown", async (_event, _ctx) => {
|
|
532
|
+
// Stop hot-reload watcher — fs.watch handles hold OS resources
|
|
533
|
+
if (hotReload) {
|
|
534
|
+
hotReload.stop();
|
|
535
|
+
hotReload = null;
|
|
536
|
+
}
|
|
537
|
+
// Persist rule mtimes so the next session can show the diff
|
|
538
|
+
persistRuleMtimes();
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
542
|
+
const sections: string[] = [];
|
|
543
|
+
let totalRulesTokens = 0;
|
|
544
|
+
|
|
545
|
+
// pi's own resource paths (AGENTS.md / CLAUDE.md it already loaded)
|
|
546
|
+
// — used to inform rule globs, not to dedup context (soly doesn't
|
|
547
|
+
// load context files).
|
|
548
|
+
const piPaths = (event.systemPromptOptions.contextFiles ?? []).map((c) => c.path);
|
|
549
|
+
|
|
550
|
+
// 1. Rules section
|
|
551
|
+
const allRules = combinedRules();
|
|
552
|
+
if (allRules.length > 0) {
|
|
553
|
+
const promptFiles = extractFilePathsFromPrompt(event.prompt);
|
|
554
|
+
const activeGlobs = [...new Set([...promptFiles, ...piPaths])];
|
|
555
|
+
|
|
556
|
+
const hasPhase = phaseRules.length > 0;
|
|
557
|
+
const { section, loaded } = buildRulesSection(allRules, activeGlobs, {
|
|
558
|
+
phaseNumber: state.currentPhase?.number,
|
|
559
|
+
groupByPhase: hasPhase,
|
|
560
|
+
});
|
|
561
|
+
rulesLoaded = loaded;
|
|
562
|
+
if (section) {
|
|
563
|
+
sections.push(section);
|
|
564
|
+
totalRulesTokens = Math.ceil(section.length / 4);
|
|
565
|
+
}
|
|
566
|
+
} else {
|
|
567
|
+
rulesLoaded = [];
|
|
568
|
+
}
|
|
569
|
+
lastRulesTokens = totalRulesTokens;
|
|
570
|
+
|
|
571
|
+
// 2. Project state section
|
|
572
|
+
if (state.exists) {
|
|
573
|
+
const section = buildProjectStateSection(state);
|
|
574
|
+
if (section) sections.push(section);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// 2.5. Cross-extension integrations: dynamically mention only the
|
|
578
|
+
// sibling pi-extensions that are actually loaded. Driven by
|
|
579
|
+
// `integrations.ts` registry — add new entries there.
|
|
580
|
+
const integrationSection = buildIntegrationsSection(pi.getActiveTools());
|
|
581
|
+
if (integrationSection) {
|
|
582
|
+
sections.push(integrationSection);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// 3.5. Project intent (zero-point docs) — always injected when present
|
|
586
|
+
if (lastIntentSection) {
|
|
587
|
+
sections.push(lastIntentSection);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// 3.6. Inline intent bodies (opt-in via frontmatter `inline: true`)
|
|
591
|
+
const inlineBodies = loadInlineIntentBodies(intentDocs);
|
|
592
|
+
for (const ib of inlineBodies) {
|
|
593
|
+
sections.push(`\n### intent: ${ib.relPath}\n\n${ib.body}`);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// 4. Git context section (always injected when available — cheap, high signal)
|
|
597
|
+
if (lastGitSection) {
|
|
598
|
+
sections.push(lastGitSection);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// 5. Project env section (cheap; high signal for tool/script choice)
|
|
602
|
+
if (lastEnvSection) {
|
|
603
|
+
sections.push(lastEnvSection);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// 6. Project layout (code map) — always injected when available
|
|
607
|
+
if (lastCodeMapSection) {
|
|
608
|
+
sections.push(lastCodeMapSection);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// 7. Behavioral nudge
|
|
612
|
+
const heuristics = classifyTaskHeuristics(event.prompt);
|
|
613
|
+
sections.push(buildNudgeSection(heuristics));
|
|
614
|
+
|
|
615
|
+
// 7.5 Soly drift reminder — injected when the user has been doing
|
|
616
|
+
// non-soly work for several turns. Throttled: at most once per
|
|
617
|
+
// REMINDER_THRESHOLD turns. Resets when a soly verb is parsed.
|
|
618
|
+
if (
|
|
619
|
+
solyDrift.turnsSinceLastSolyVerb >= solyDrift.REMINDER_THRESHOLD &&
|
|
620
|
+
solyDrift.turnsSinceLastSolyVerb - solyDrift.lastReminderAt >= solyDrift.REMINDER_THRESHOLD
|
|
621
|
+
) {
|
|
622
|
+
const reminder = buildDriftReminder(solyDrift.turnsSinceLastSolyVerb);
|
|
623
|
+
if (reminder) {
|
|
624
|
+
sections.push(`\n## soly drift\n\n${reminder}\n`);
|
|
625
|
+
solyDrift.lastReminderAt = solyDrift.turnsSinceLastSolyVerb;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
if (heuristics.nonTrivial || heuristics.researchHeavy) {
|
|
629
|
+
nudgeActiveForTask = true;
|
|
630
|
+
lastNudgePromptKey = event.prompt.slice(0, 200);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// 7. Update status bar
|
|
634
|
+
updateStatus(ctx);
|
|
635
|
+
|
|
636
|
+
if (sections.length === 0) return;
|
|
637
|
+
return {
|
|
638
|
+
systemPrompt: event.systemPrompt + sections.join("\n"),
|
|
639
|
+
};
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
pi.on("input", async (event, ctx) => {
|
|
643
|
+
// Nudge notify — runs BEFORE workflows/* (which may transform).
|
|
644
|
+
// Soft UI hint, never blocks the input.
|
|
645
|
+
if (event.source !== "interactive") return;
|
|
646
|
+
const text = event.text.trim();
|
|
647
|
+
if (!text || text.startsWith("/")) return;
|
|
648
|
+
if (text.startsWith("soly ")) return; // workflow verb — let workflows handle it
|
|
649
|
+
if (text.slice(0, 200) === lastNudgePromptKey && nudgeActiveForTask) return;
|
|
650
|
+
|
|
651
|
+
const heuristics = classifyTaskHeuristics(text);
|
|
652
|
+
if (!heuristics.nonTrivial && !heuristics.researchHeavy) return;
|
|
653
|
+
|
|
654
|
+
const angle =
|
|
655
|
+
heuristics.suggestedAngles[0] ?? "want me to confirm assumptions before I start?";
|
|
656
|
+
|
|
657
|
+
const label = heuristics.researchHeavy
|
|
658
|
+
? "soly: research-heavy prompt — clarifying question?"
|
|
659
|
+
: "soly: non-trivial prompt — clarifying question?";
|
|
660
|
+
|
|
661
|
+
ctx.ui.notify(`${label} ${angle}`, "info");
|
|
662
|
+
nudgeActiveForTask = true;
|
|
663
|
+
lastNudgePromptKey = text.slice(0, 200);
|
|
664
|
+
|
|
665
|
+
// Drift counter — non-soly, non-slash, non-trivial prompt.
|
|
666
|
+
// Workflow verbs reset this via onWorkflowUsed callback.
|
|
667
|
+
solyDrift.turnsSinceLastSolyVerb += 1;
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
671
|
+
const beforeRules = rules.map((r) => `${r.source}:${r.relPath}:${r.mtimeMs}`).join(",");
|
|
672
|
+
const beforeStateUpdated = state.lastUpdated;
|
|
673
|
+
|
|
674
|
+
refreshRules();
|
|
675
|
+
refreshState();
|
|
676
|
+
|
|
677
|
+
const afterRules = rules.map((r) => `${r.source}:${r.relPath}:${r.mtimeMs}`).join(",");
|
|
678
|
+
const rulesChanged = beforeRules !== afterRules;
|
|
679
|
+
const stateChanged = beforeStateUpdated !== state.lastUpdated;
|
|
680
|
+
|
|
681
|
+
// Update session stats — count assistant turns + rough token estimate
|
|
682
|
+
const entries = ctx.sessionManager.getBranch();
|
|
683
|
+
let turns = 0;
|
|
684
|
+
let tokens = 0;
|
|
685
|
+
for (const entry of entries) {
|
|
686
|
+
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
687
|
+
turns++;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
const usage = ctx.getContextUsage();
|
|
691
|
+
if (usage) tokens = usage.tokens ?? 0;
|
|
692
|
+
sessionStats = { turns, tokensEstimate: tokens };
|
|
693
|
+
|
|
694
|
+
// Refresh git context (cheap; debounced naturally by turn cadence)
|
|
695
|
+
if (sessionCwd) {
|
|
696
|
+
const newGit = await readGitContext(sessionCwd);
|
|
697
|
+
if (
|
|
698
|
+
newGit.branch !== gitContext.branch ||
|
|
699
|
+
newGit.statusShort !== gitContext.statusShort
|
|
700
|
+
) {
|
|
701
|
+
gitContext = newGit;
|
|
702
|
+
lastGitSection = buildGitSection(gitContext);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Refresh intent (zero-point docs) — cheap, but skip if last was recent
|
|
707
|
+
const beforeIntentCount = intentDocs.length;
|
|
708
|
+
refreshIntent();
|
|
709
|
+
if (intentDocs.length !== beforeIntentCount) {
|
|
710
|
+
// re-render status (intentionally don't push a section — system
|
|
711
|
+
// prompt regenerates next turn)
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (rulesChanged || stateChanged) {
|
|
715
|
+
updateStatus(ctx);
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
}
|