pi-gsd 1.12.3 → 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 +130 -112
- package/package.json +1 -1
|
@@ -78,20 +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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
+
}
|
|
129
|
+
|
|
130
|
+
for (const part of parts) {
|
|
131
|
+
const p = part.trim();
|
|
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
|
+
}
|
|
94
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) => {
|
|
182
|
+
const includePattern = /<gsd-include\s+path="([^"]+)"(?:\s+select="([^"]*)")?\s*\/>/g;
|
|
95
183
|
|
|
96
184
|
// Package harness fallback path
|
|
97
185
|
const extFile = typeof __filename !== "undefined" ? __filename : "";
|
|
@@ -100,116 +188,46 @@ export default function (pi: ExtensionAPI) {
|
|
|
100
188
|
: "";
|
|
101
189
|
|
|
102
190
|
const errors: string[] = [];
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
for (const match of includes) {
|
|
106
|
-
const fullMatch = match[0];
|
|
107
|
-
const filePath = match[1];
|
|
108
|
-
const selectExpr = match[2] ?? "";
|
|
109
|
-
|
|
110
|
-
// ── Resolve file path ───────────────────────────────────────
|
|
111
|
-
const subPath = filePath.replace(/^\.pi\/gsd\//, "");
|
|
112
|
-
const candidates = [
|
|
113
|
-
join(ctx.cwd, filePath),
|
|
114
|
-
...(filePath.startsWith(".pi/gsd/") && pkgHarness
|
|
115
|
-
? [join(pkgHarness, subPath)]
|
|
116
|
-
: []),
|
|
117
|
-
];
|
|
118
|
-
|
|
119
|
-
let raw: string | null = null;
|
|
120
|
-
for (const c of candidates) {
|
|
121
|
-
try {
|
|
122
|
-
if (existsSync(c)) { raw = readFileSync(c, "utf8"); break; }
|
|
123
|
-
} catch { /* try next */ }
|
|
124
|
-
}
|
|
125
|
-
if (raw === null) {
|
|
126
|
-
errors.push(`File not found: ${filePath}`);
|
|
127
|
-
continue;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// ── Apply selector ─────────────────────────────────────────
|
|
131
|
-
let result = raw;
|
|
132
|
-
if (selectExpr) {
|
|
133
|
-
const parts = selectExpr.split("|");
|
|
134
|
-
if (parts.length > 2) {
|
|
135
|
-
errors.push(`Invalid selector (max 2 segments): ${selectExpr}`);
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
// lines: must be standalone — reject any chain involving lines
|
|
139
|
-
if (parts.length > 1 && parts.some((p) => p.trim().startsWith("lines:"))) {
|
|
140
|
-
errors.push(`lines: cannot be chained — use it alone: ${selectExpr}`);
|
|
141
|
-
continue;
|
|
142
|
-
}
|
|
191
|
+
const messages = event.messages;
|
|
143
192
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const prev = i > 0 ? parts[i - 1].trim().split(":")[0] : null;
|
|
193
|
+
for (const msg of messages) {
|
|
194
|
+
if (msg.role !== "user") continue;
|
|
147
195
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
"i",
|
|
153
|
-
);
|
|
154
|
-
const tagMatch = result.match(tagRe);
|
|
155
|
-
if (!tagMatch) {
|
|
156
|
-
errors.push(`Tag <${tagName}> not found in ${filePath}`);
|
|
157
|
-
result = "";
|
|
158
|
-
break;
|
|
159
|
-
}
|
|
160
|
-
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;
|
|
161
200
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
);
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
: result.slice(startIdx, startIdx + nextHeading).trim();
|
|
181
|
-
|
|
182
|
-
} else if (part.startsWith("lines:")) {
|
|
183
|
-
const rangeMatch = part.match(/^lines:(\d+)-(\d+)$/);
|
|
184
|
-
if (!rangeMatch) {
|
|
185
|
-
errors.push(`Invalid lines selector: ${part}`);
|
|
186
|
-
result = "";
|
|
187
|
-
break;
|
|
188
|
-
}
|
|
189
|
-
const start = parseInt(rangeMatch[1], 10) - 1;
|
|
190
|
-
const end = parseInt(rangeMatch[2], 10);
|
|
191
|
-
result = result.split("\n").slice(start, end).join("\n");
|
|
192
|
-
|
|
193
|
-
} else {
|
|
194
|
-
errors.push(`Unknown selector: ${part}`);
|
|
195
|
-
result = "";
|
|
196
|
-
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);
|
|
197
219
|
}
|
|
220
|
+
block.text = transformed;
|
|
198
221
|
}
|
|
199
|
-
if (result === "") continue; // error already logged
|
|
200
222
|
}
|
|
201
|
-
|
|
202
|
-
transformed = transformed.replace(fullMatch, result);
|
|
203
223
|
}
|
|
204
224
|
|
|
205
225
|
if (errors.length > 0) {
|
|
206
|
-
ctx.ui.notify("\u274c GSD include failed:\n" + errors.map((e) => " \u2022 " + e).join("\n"),
|
|
207
|
-
|
|
208
|
-
);
|
|
209
|
-
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
|
|
210
228
|
}
|
|
211
229
|
|
|
212
|
-
return {
|
|
230
|
+
return { messages };
|
|
213
231
|
});
|
|
214
232
|
|
|
215
233
|
// ── session_start: GSD update check ──────────────────────────────────────
|