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.
@@ -0,0 +1,64 @@
1
+ // =============================================================================
2
+ // integrations.ts — Registry of known cross-extension integrations
3
+ // =============================================================================
4
+ //
5
+ // soly treats itself as a platform: it composes with sibling pi-extensions
6
+ // when they're loaded. This file is the single source of truth for which
7
+ // external extensions we know about and how to advertise them in the
8
+ // system prompt. Add a new entry when a sibling extension ships a tool
9
+ // that the LLM should reach for in a soly-aware way.
10
+ //
11
+ // Detection: passive (read `pi.getActiveTools()` from `before_agent_start`).
12
+ // We only mention extensions that are *actually installed* — no noise about
13
+ // extensions the user hasn't loaded.
14
+ // =============================================================================
15
+
16
+ export interface KnownIntegration {
17
+ /** Tool name as registered with `pi.registerTool`. Case-sensitive match. */
18
+ tool: string;
19
+ /** Short human label for the extension package (e.g. "pi-ask"). */
20
+ extension: string;
21
+ /** One-line summary of what the tool does. */
22
+ summary: string;
23
+ /** When/how the LLM should use this tool inside a soly workflow. */
24
+ whenToUse: string;
25
+ }
26
+
27
+ /** Single registry — add new entries here, no other code changes needed. */
28
+ export const KNOWN_INTEGRATIONS: KnownIntegration[] = [
29
+ {
30
+ tool: "ask_pro",
31
+ extension: "pi-ask",
32
+ summary: "Multi-question tabbed picker (Claude Code style).",
33
+ whenToUse:
34
+ "Use instead of `soly_ask_user` for `soly discuss` flows when you have multiple related questions. PREFERRED in `soly discuss` when available.",
35
+ },
36
+ {
37
+ tool: "todo_update",
38
+ extension: "pi-todo",
39
+ summary: "Live, user-visible task list rendered in the footer.",
40
+ whenToUse:
41
+ "During `soly execute <plan>`, seed todos at the start with one item per `<task>` so the user sees real-time progress. Update as you work: pending → in_progress → completed. Clear the list when the SUMMARY is committed.",
42
+ },
43
+ ];
44
+
45
+ /** Build the cross-extension integrations section for the system prompt.
46
+ * Returns null when none of the registered tools are present (no noise). */
47
+ export function buildIntegrationsSection(activeTools: readonly string[]): string | null {
48
+ const installed = KNOWN_INTEGRATIONS.filter((i) => activeTools.includes(i.tool));
49
+ if (installed.length === 0) return null;
50
+
51
+ const lines: string[] = [];
52
+ lines.push("");
53
+ lines.push("## Cross-extension integrations (active in this session)");
54
+ lines.push("");
55
+ lines.push(
56
+ "The following optional pi-extensions are loaded. Use their tools when the situation matches — they exist to make your output more useful to the user.",
57
+ );
58
+ lines.push("");
59
+ for (const i of installed) {
60
+ lines.push(`- \`${i.tool}\` (from \`${i.extension}\`) — ${i.summary}`);
61
+ lines.push(` When: ${i.whenToUse}`);
62
+ }
63
+ return lines.join("\n");
64
+ }
package/intent.ts ADDED
@@ -0,0 +1,303 @@
1
+ // =============================================================================
2
+ // intent.ts — Project intent loader (the "0 point" of every soly project)
3
+ // =============================================================================
4
+ //
5
+ // `.soly/docs/` is the zero-point of the project: documents written BEFORE
6
+ // any soly plans, research, or code. It holds the user's vision, business
7
+ // context, and architectural intent. Other soly artifacts (STATE, PLANS,
8
+ // RESEARCH) flow FROM this input.
9
+ //
10
+ // Supported files: `.md` (full text) and `.html` (parsed for title + preview).
11
+ // Nested directories are supported (e.g. `.soly/docs/api/auth.md`).
12
+ //
13
+ // Convention: any document in `.soly/docs/` is loaded into the system
14
+ // prompt as "project intent". This is separate from:
15
+ // - rules (`.soly/rules/`) — how to behave
16
+ // - state (`.soly/STATE.md`, ROADMAP.md) — where we are
17
+ // - planning (PLAN.md, CONTEXT.md) — what to do next
18
+ //
19
+ // Optional: `.soly/phases/<N>/docs/` is also scanned if the directory exists
20
+ // (for backward compat / phase-specific intent). Not required.
21
+ // =============================================================================
22
+
23
+ import * as fs from "node:fs";
24
+ import * as path from "node:path";
25
+ import { formatTok, resolveImports } from "./core.js";
26
+ import { extractTitleAndPreview, stripHtml } from "./html.js";
27
+
28
+ const DOC_EXTS = new Set([".md", ".html", ".htm"]);
29
+
30
+ export type IntentKind = "md" | "html";
31
+
32
+ export interface IntentDoc {
33
+ relPath: string;
34
+ absPath: string;
35
+ kind: IntentKind;
36
+ title: string;
37
+ preview: string;
38
+ tokens: number;
39
+ /** True if this came from a phase-specific docs dir. */
40
+ phaseNumber?: number;
41
+ /** Size cap, files larger than this are still indexed but flagged. */
42
+ oversized: boolean;
43
+ }
44
+
45
+ const MAX_PREVIEW_CHARS = 200;
46
+ const MAX_FILE_BYTES = 256 * 1024; // 256KB cap on indexed files
47
+
48
+ function readFirstLineOfFile(absPath: string, max = 120): string {
49
+ try {
50
+ const stat = fs.statSync(absPath);
51
+ if (stat.size > MAX_FILE_BYTES) {
52
+ const fd = fs.openSync(absPath, "r");
53
+ try {
54
+ const buf = Buffer.alloc(max);
55
+ fs.readSync(fd, buf, 0, max, 0);
56
+ return buf.toString("utf-8").split(/\r?\n/)[0]?.trim() ?? "";
57
+ } finally {
58
+ fs.closeSync(fd);
59
+ }
60
+ }
61
+ const content = fs.readFileSync(absPath, "utf-8");
62
+ return content.split(/\r?\n/)[0]?.trim() ?? "";
63
+ } catch {
64
+ return "";
65
+ }
66
+ }
67
+
68
+ // ---- Walker ----
69
+
70
+ function walkIntentDir(
71
+ absDir: string,
72
+ relBase: string,
73
+ phaseNumber: number | undefined,
74
+ out: IntentDoc[],
75
+ ): void {
76
+ if (!fs.existsSync(absDir)) return;
77
+ let entries: fs.Dirent[];
78
+ try {
79
+ entries = fs.readdirSync(absDir, { withFileTypes: true });
80
+ } catch {
81
+ return;
82
+ }
83
+ for (const e of entries) {
84
+ if (e.name.startsWith(".")) continue;
85
+ const abs = path.join(absDir, e.name);
86
+ const rel = relBase ? `${relBase}/${e.name}` : e.name;
87
+ if (e.isDirectory()) {
88
+ walkIntentDir(abs, rel, phaseNumber, out);
89
+ continue;
90
+ }
91
+ if (!e.isFile()) continue;
92
+ const ext = path.extname(e.name).toLowerCase();
93
+ if (!DOC_EXTS.has(ext)) continue;
94
+
95
+ let stat: fs.Stats;
96
+ try {
97
+ stat = fs.statSync(abs);
98
+ } catch {
99
+ continue;
100
+ }
101
+
102
+ const oversized = stat.size > MAX_FILE_BYTES;
103
+ const kind: IntentKind = ext === ".md" ? "md" : "html";
104
+
105
+ let title = "";
106
+ let preview = "";
107
+ try {
108
+ // For oversized files, only read the first 64KB
109
+ const readBytes = Math.min(stat.size, 64 * 1024);
110
+ const buf = Buffer.alloc(readBytes);
111
+ const fd = fs.openSync(abs, "r");
112
+ try {
113
+ fs.readSync(fd, buf, 0, readBytes, 0);
114
+ } finally {
115
+ fs.closeSync(fd);
116
+ }
117
+ const content = buf.toString("utf-8");
118
+ // `ext` is `path.extname` output, always lowercased — cast to the
119
+ // narrow literal union the extractor expects.
120
+ const extNorm = (ext === ".md" || ext === ".html" || ext === ".htm" ? ext : ".md") as
121
+ | ".md"
122
+ | ".html"
123
+ | ".htm";
124
+ const extracted = extractTitleAndPreview(content, extNorm, { maxPreview: MAX_PREVIEW_CHARS });
125
+ title = extracted.title;
126
+ preview = extracted.preview || stripHtml(content).slice(0, MAX_PREVIEW_CHARS);
127
+ } catch {
128
+ // best effort
129
+ }
130
+
131
+ // Fallback title = filename without extension
132
+ if (!title) {
133
+ title = path.basename(e.name, ext).replace(/[-_]+/g, " ");
134
+ }
135
+
136
+ out.push({
137
+ relPath: rel,
138
+ absPath: abs,
139
+ kind,
140
+ title,
141
+ preview,
142
+ tokens: Math.ceil(stat.size / 4),
143
+ phaseNumber,
144
+ oversized,
145
+ });
146
+ }
147
+ }
148
+
149
+ export function loadIntentDocs(cwd: string, currentPhaseNumber?: number): IntentDoc[] {
150
+ const out: IntentDoc[] = [];
151
+ const docsRoot = path.join(cwd, ".soly", "docs");
152
+ walkIntentDir(docsRoot, "", undefined, out);
153
+
154
+ // Optional: phase-specific docs (only if current phase is known)
155
+ if (currentPhaseNumber != null) {
156
+ // We don't know the slug here without re-walking phases; scan any
157
+ // directory in .soly/phases/ whose name starts with the phase number.
158
+ // Skip if no phases dir exists.
159
+ const phasesRoot = path.join(cwd, ".soly", "phases");
160
+ if (fs.existsSync(phasesRoot)) {
161
+ try {
162
+ const phaseEntries = fs.readdirSync(phasesRoot, { withFileTypes: true });
163
+ for (const pe of phaseEntries) {
164
+ if (!pe.isDirectory()) continue;
165
+ const m = pe.name.match(/^(\d+)/);
166
+ if (!m) continue;
167
+ if (parseInt(m[1], 10) !== currentPhaseNumber) continue;
168
+ const fullPhaseDocsDir = path.join(phasesRoot, pe.name, "docs");
169
+ walkIntentDir(fullPhaseDocsDir, pe.name + "/docs", currentPhaseNumber, out);
170
+ }
171
+ } catch {
172
+ // best effort
173
+ }
174
+ }
175
+ }
176
+
177
+ // Sort: top-level files first (depth), then alphabetically
178
+ out.sort((a, b) => {
179
+ const da = a.relPath.split("/").length;
180
+ const db = b.relPath.split("/").length;
181
+ if (da !== db) return da - db;
182
+ return a.relPath.localeCompare(b.relPath);
183
+ });
184
+
185
+ return out;
186
+ }
187
+
188
+ export interface IntentSection {
189
+ hasContent: boolean;
190
+ section: string;
191
+ }
192
+
193
+ /** Render the "## project intent" section for the system prompt. */
194
+ export function buildIntentSection(intent: IntentDoc[]): IntentSection {
195
+ if (intent.length === 0) {
196
+ return { hasContent: false, section: "" };
197
+ }
198
+
199
+ const lines: string[] = ["", "## project intent (from .soly/docs/)", ""];
200
+ lines.push(
201
+ "These documents are the **0 point** of this project — the user's vision, business context, and design intent, written BEFORE any soly plans. Read them first when planning, discussing, or executing. If implementation diverges from intent, fix one or the other — don't let drift compound.",
202
+ );
203
+ lines.push("");
204
+
205
+ // Group: always (top-level) vs phase-specific
206
+ const always = intent.filter((d) => d.phaseNumber == null);
207
+ const phaseSpecific = intent.filter((d) => d.phaseNumber != null);
208
+
209
+ if (always.length > 0) {
210
+ lines.push("**Always read first:**");
211
+ lines.push("");
212
+ for (const d of always) {
213
+ const title = d.title || d.relPath;
214
+ const tag = d.kind === "html" ? "html" : "md";
215
+ const oversize = d.oversized ? " (large file — use soly_snippet to read)" : "";
216
+ lines.push(`- \`${d.relPath}\` (${tag}, ${formatTok(d.tokens)} tokens)${oversize}`);
217
+ lines.push(` - **${title}**`);
218
+ if (d.preview) {
219
+ lines.push(` - ${d.preview.slice(0, 180)}${d.preview.length > 180 ? "…" : ""}`);
220
+ }
221
+ }
222
+ lines.push("");
223
+ }
224
+
225
+ if (phaseSpecific.length > 0) {
226
+ lines.push("**Phase-specific** (relevant to active phase):");
227
+ lines.push("");
228
+ for (const d of phaseSpecific) {
229
+ const title = d.title || d.relPath;
230
+ lines.push(`- \`${d.relPath}\` (phase ${d.phaseNumber}) — **${title}**`);
231
+ if (d.preview) {
232
+ lines.push(` - ${d.preview.slice(0, 180)}${d.preview.length > 180 ? "…" : ""}`);
233
+ }
234
+ }
235
+ lines.push("");
236
+ }
237
+
238
+ lines.push(
239
+ "Use `soly_intent` to refresh this list, `soly_doc_search` for keyword search across all docs (including project .md), and `soly_snippet` to read a specific range.",
240
+ );
241
+
242
+ return { hasContent: true, section: lines.join("\n") };
243
+ }
244
+
245
+ // ---- Inline body resolution ----
246
+ //
247
+ // For .md intent docs, optionally inline the FULL body into the system prompt
248
+ // (with @import resolution). Off by default — index is usually enough. Turn on
249
+ // per-doc via frontmatter `inline: true`.
250
+
251
+ export interface IntentInlineDoc {
252
+ relPath: string;
253
+ body: string;
254
+ tokens: number;
255
+ }
256
+
257
+ function parseIntentFrontmatter(raw: string): { inline: boolean; body: string } {
258
+ const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
259
+ if (!m) return { inline: false, body: raw };
260
+ const yaml = m[1];
261
+ const body = m[2];
262
+ const inlineMatch = yaml.match(/^\s*inline\s*:\s*true\s*$/m);
263
+ return { inline: !!inlineMatch, body };
264
+ }
265
+
266
+ export function loadInlineIntentBodies(intent: IntentDoc[]): IntentInlineDoc[] {
267
+ const out: IntentInlineDoc[] = [];
268
+ for (const d of intent) {
269
+ if (d.kind !== "md") continue; // Only .md can opt-in to inlining
270
+ try {
271
+ const raw = fs.readFileSync(d.absPath, "utf-8");
272
+ const { inline, body } = parseIntentFrontmatter(raw);
273
+ if (!inline) continue;
274
+ if (d.oversized) continue; // Skip oversized even if opted-in
275
+
276
+ // Apply @import resolution (with cycle + depth protection).
277
+ // Inlined docs are read-only, so we use a fresh globalSeen set.
278
+ const globalSeen = new Set<string>([d.absPath]);
279
+ const resolved = resolveImports(body, d.absPath, globalSeen, 0, {
280
+ imported: [],
281
+ });
282
+
283
+ // Token cap (defense against accidental huge inlines that slipped
284
+ // past the bytes cap, e.g. dense code with low whitespace).
285
+ const tokens = Math.ceil(resolved.length / 4);
286
+ const TOKEN_CAP = 2000;
287
+ const finalBody =
288
+ tokens > TOKEN_CAP
289
+ ? resolved.slice(0, TOKEN_CAP * 4) +
290
+ `\n\n<!-- inlined truncated to ${TOKEN_CAP} tokens (${tokens} total); use soly_snippet for the full file -->`
291
+ : resolved;
292
+
293
+ out.push({
294
+ relPath: d.relPath,
295
+ body: finalBody,
296
+ tokens: Math.ceil(finalBody.length / 4),
297
+ });
298
+ } catch {
299
+ // skip unreadable
300
+ }
301
+ }
302
+ return out;
303
+ }