pi-gsd 1.12.2 → 1.12.4
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/.gsd/extensions/pi-gsd-hooks.ts +128 -111
- package/package.json +1 -1
|
@@ -78,21 +78,108 @@ const ensureHarnessSymlink = (cwd: string): void => {
|
|
|
78
78
|
|
|
79
79
|
|
|
80
80
|
export default function (pi: ExtensionAPI) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
81
|
+
/** Resolve a single <gsd-include> match: file lookup + selector extraction. */
|
|
82
|
+
function resolveGsdInclude(
|
|
83
|
+
match: RegExpMatchArray,
|
|
84
|
+
cwd: string,
|
|
85
|
+
pkgHarness: string,
|
|
86
|
+
errors: string[],
|
|
87
|
+
): string | null {
|
|
88
|
+
const filePath = match[1];
|
|
89
|
+
const selectExpr = match[2] ?? "";
|
|
90
|
+
|
|
91
|
+
// ── Resolve file path ───────────────────────────────────────
|
|
92
|
+
const subPath = filePath.replace(/^\.pi\/gsd\//, "");
|
|
93
|
+
const candidates = [
|
|
94
|
+
join(cwd, filePath),
|
|
95
|
+
...(filePath.startsWith(".pi/gsd/") && pkgHarness
|
|
96
|
+
? [join(pkgHarness, subPath)]
|
|
97
|
+
: []),
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
let raw: string | null = null;
|
|
101
|
+
for (const c of candidates) {
|
|
102
|
+
try {
|
|
103
|
+
if (existsSync(c)) {
|
|
104
|
+
raw = readFileSync(c, "utf8");
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
/* try next */
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (raw === null) {
|
|
112
|
+
errors.push("File not found: " + filePath);
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Apply selector ─────────────────────────────────────────
|
|
117
|
+
let result = raw;
|
|
118
|
+
if (!selectExpr) return result;
|
|
119
|
+
|
|
120
|
+
const parts = selectExpr.split("|");
|
|
121
|
+
if (parts.length > 2) {
|
|
122
|
+
errors.push("Invalid selector (max 2 segments): " + selectExpr);
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
if (parts.length > 1 && parts.some((p) => p.trim().startsWith("lines:"))) {
|
|
126
|
+
errors.push("lines: cannot be chained — use it alone: " + selectExpr);
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
88
129
|
|
|
89
|
-
|
|
90
|
-
|
|
130
|
+
for (const part of parts) {
|
|
131
|
+
const p = part.trim();
|
|
91
132
|
|
|
133
|
+
if (p.startsWith("tag:")) {
|
|
134
|
+
const tagName = p.slice(4);
|
|
135
|
+
const tagRe = new RegExp("<" + tagName + ">([\\s\\S]*?)</" + tagName + ">", "i");
|
|
136
|
+
const tagMatch = result.match(tagRe);
|
|
137
|
+
if (!tagMatch) {
|
|
138
|
+
errors.push("Tag <" + tagName + "> not found in " + filePath);
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
result = tagMatch[1].trim();
|
|
142
|
+
} else if (p.startsWith("heading:")) {
|
|
143
|
+
const headingText = p.slice(8);
|
|
144
|
+
const escaped = headingText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
145
|
+
const headingRe = new RegExp("(^|\\n)(#{1,6})\\s+" + escaped + "\\s*\\n");
|
|
146
|
+
const hMatch = result.match(headingRe);
|
|
147
|
+
if (!hMatch) {
|
|
148
|
+
errors.push('Heading "' + headingText + '" not found in ' + filePath);
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
const level = hMatch[2].length;
|
|
152
|
+
const startIdx = (hMatch.index ?? 0) + hMatch[0].length;
|
|
153
|
+
const nextHeading = result.slice(startIdx).search(new RegExp("\\n#{1," + level + "}\\s"));
|
|
154
|
+
result =
|
|
155
|
+
nextHeading === -1
|
|
156
|
+
? result.slice(startIdx).trim()
|
|
157
|
+
: result.slice(startIdx, startIdx + nextHeading).trim();
|
|
158
|
+
} else if (p.startsWith("lines:")) {
|
|
159
|
+
const rangeMatch = p.match(/^lines:(\d+)-(\d+)$/);
|
|
160
|
+
if (!rangeMatch) {
|
|
161
|
+
errors.push("Invalid lines selector: " + p);
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
const start = parseInt(rangeMatch[1], 10) - 1;
|
|
165
|
+
const end = parseInt(rangeMatch[2], 10);
|
|
166
|
+
result = result.split("\n").slice(start, end).join("\n");
|
|
167
|
+
} else {
|
|
168
|
+
errors.push("Unknown selector: " + p);
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return result;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── context: <gsd-include> injection ────────────────────────────────────────
|
|
177
|
+
// Fires AFTER template expansion, before each LLM call.
|
|
178
|
+
// Scans user messages for <gsd-include path="..." select="..." /> tags,
|
|
179
|
+
// resolves files, applies selectors, replaces tags with content.
|
|
180
|
+
// On ANY failure: red error + return empty messages to block the LLM call.
|
|
181
|
+
pi.on("context", async (event, ctx) => {
|
|
92
182
|
const includePattern = /<gsd-include\s+path="([^"]+)"(?:\s+select="([^"]*)")?\s*\/>/g;
|
|
93
|
-
const includes = [...text.matchAll(includePattern)];
|
|
94
|
-
ctx.ui.notify(`[GSD:DBG] includes=${includes.length}`, "info");
|
|
95
|
-
if (includes.length === 0) return { action: "continue" };
|
|
96
183
|
|
|
97
184
|
// Package harness fallback path
|
|
98
185
|
const extFile = typeof __filename !== "undefined" ? __filename : "";
|
|
@@ -101,116 +188,46 @@ export default function (pi: ExtensionAPI) {
|
|
|
101
188
|
: "";
|
|
102
189
|
|
|
103
190
|
const errors: string[] = [];
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
for (const match of includes) {
|
|
107
|
-
const fullMatch = match[0];
|
|
108
|
-
const filePath = match[1];
|
|
109
|
-
const selectExpr = match[2] ?? "";
|
|
110
|
-
|
|
111
|
-
// ── Resolve file path ───────────────────────────────────────
|
|
112
|
-
const subPath = filePath.replace(/^\.pi\/gsd\//, "");
|
|
113
|
-
const candidates = [
|
|
114
|
-
join(ctx.cwd, filePath),
|
|
115
|
-
...(filePath.startsWith(".pi/gsd/") && pkgHarness
|
|
116
|
-
? [join(pkgHarness, subPath)]
|
|
117
|
-
: []),
|
|
118
|
-
];
|
|
119
|
-
|
|
120
|
-
let raw: string | null = null;
|
|
121
|
-
for (const c of candidates) {
|
|
122
|
-
try {
|
|
123
|
-
if (existsSync(c)) { raw = readFileSync(c, "utf8"); break; }
|
|
124
|
-
} catch { /* try next */ }
|
|
125
|
-
}
|
|
126
|
-
if (raw === null) {
|
|
127
|
-
errors.push(`File not found: ${filePath}`);
|
|
128
|
-
continue;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// ── Apply selector ─────────────────────────────────────────
|
|
132
|
-
let result = raw;
|
|
133
|
-
if (selectExpr) {
|
|
134
|
-
const parts = selectExpr.split("|");
|
|
135
|
-
if (parts.length > 2) {
|
|
136
|
-
errors.push(`Invalid selector (max 2 segments): ${selectExpr}`);
|
|
137
|
-
continue;
|
|
138
|
-
}
|
|
139
|
-
// lines: must be standalone — reject any chain involving lines
|
|
140
|
-
if (parts.length > 1 && parts.some((p) => p.trim().startsWith("lines:"))) {
|
|
141
|
-
errors.push(`lines: cannot be chained — use it alone: ${selectExpr}`);
|
|
142
|
-
continue;
|
|
143
|
-
}
|
|
191
|
+
const messages = event.messages;
|
|
144
192
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const prev = i > 0 ? parts[i - 1].trim().split(":")[0] : null;
|
|
193
|
+
for (const msg of messages) {
|
|
194
|
+
if (msg.role !== "user") continue;
|
|
148
195
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
"i",
|
|
154
|
-
);
|
|
155
|
-
const tagMatch = result.match(tagRe);
|
|
156
|
-
if (!tagMatch) {
|
|
157
|
-
errors.push(`Tag <${tagName}> not found in ${filePath}`);
|
|
158
|
-
result = "";
|
|
159
|
-
break;
|
|
160
|
-
}
|
|
161
|
-
result = tagMatch[1].trim();
|
|
196
|
+
// Handle both string content and content block arrays
|
|
197
|
+
if (typeof msg.content === "string") {
|
|
198
|
+
const includes = [...msg.content.matchAll(includePattern)];
|
|
199
|
+
if (includes.length === 0) continue;
|
|
162
200
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
);
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
: result.slice(startIdx, startIdx + nextHeading).trim();
|
|
182
|
-
|
|
183
|
-
} else if (part.startsWith("lines:")) {
|
|
184
|
-
const rangeMatch = part.match(/^lines:(\d+)-(\d+)$/);
|
|
185
|
-
if (!rangeMatch) {
|
|
186
|
-
errors.push(`Invalid lines selector: ${part}`);
|
|
187
|
-
result = "";
|
|
188
|
-
break;
|
|
189
|
-
}
|
|
190
|
-
const start = parseInt(rangeMatch[1], 10) - 1;
|
|
191
|
-
const end = parseInt(rangeMatch[2], 10);
|
|
192
|
-
result = result.split("\n").slice(start, end).join("\n");
|
|
193
|
-
|
|
194
|
-
} else {
|
|
195
|
-
errors.push(`Unknown selector: ${part}`);
|
|
196
|
-
result = "";
|
|
197
|
-
break;
|
|
201
|
+
let transformed = msg.content;
|
|
202
|
+
for (const match of includes) {
|
|
203
|
+
const replacement = resolveGsdInclude(match, ctx.cwd, pkgHarness, errors);
|
|
204
|
+
if (replacement === null) continue;
|
|
205
|
+
transformed = transformed.replace(match[0], replacement);
|
|
206
|
+
}
|
|
207
|
+
msg.content = transformed;
|
|
208
|
+
} else if (Array.isArray(msg.content)) {
|
|
209
|
+
for (const block of msg.content) {
|
|
210
|
+
if (block.type !== "text" || !block.text) continue;
|
|
211
|
+
const includes = [...block.text.matchAll(includePattern)];
|
|
212
|
+
if (includes.length === 0) continue;
|
|
213
|
+
|
|
214
|
+
let transformed = block.text;
|
|
215
|
+
for (const match of includes) {
|
|
216
|
+
const replacement = resolveGsdInclude(match, ctx.cwd, pkgHarness, errors);
|
|
217
|
+
if (replacement === null) continue;
|
|
218
|
+
transformed = transformed.replace(match[0], replacement);
|
|
198
219
|
}
|
|
220
|
+
block.text = transformed;
|
|
199
221
|
}
|
|
200
|
-
if (result === "") continue; // error already logged
|
|
201
222
|
}
|
|
202
|
-
|
|
203
|
-
transformed = transformed.replace(fullMatch, result);
|
|
204
223
|
}
|
|
205
224
|
|
|
206
225
|
if (errors.length > 0) {
|
|
207
|
-
ctx.ui.notify("\u274c GSD include failed:\n" + errors.map((e) => " \u2022 " + e).join("\n"),
|
|
208
|
-
|
|
209
|
-
);
|
|
210
|
-
return { action: "handled" };
|
|
226
|
+
ctx.ui.notify("\u274c GSD include failed:\n" + errors.map((e) => " \u2022 " + e).join("\n"), "error");
|
|
227
|
+
return { messages: [] }; // block LLM call
|
|
211
228
|
}
|
|
212
229
|
|
|
213
|
-
return {
|
|
230
|
+
return { messages };
|
|
214
231
|
});
|
|
215
232
|
|
|
216
233
|
// ── session_start: GSD update check ──────────────────────────────────────
|