@bugabinga/pi-ext-llmiterate 0.1.0
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/CHANGELOG.md +8 -0
- package/README.md +160 -0
- package/__bench__/PERF.md +47 -0
- package/__bench__/baseline.txt +4 -0
- package/__bench__/bench.ts +66 -0
- package/__bench__/benchstat.txt +8 -0
- package/__bench__/optimized.txt +20 -0
- package/__tests__/__snapshots__/core.test.ts.snap +17 -0
- package/__tests__/__snapshots__/ui.test.ts.snap +15 -0
- package/__tests__/core.test.ts +268 -0
- package/__tests__/lock.test.ts +80 -0
- package/__tests__/rpc.test.ts +124 -0
- package/__tests__/ui.test.ts +80 -0
- package/__tests__/watcher.test.ts +133 -0
- package/assets/workflow_suite.gif +0 -0
- package/bun.lock +336 -0
- package/core.ts +489 -0
- package/index.ts +434 -0
- package/lock.ts +139 -0
- package/package.json +19 -0
- package/rpc.ts +228 -0
- package/types.ts +45 -0
- package/ui.ts +103 -0
- package/watcher.ts +191 -0
package/core.ts
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_DEBOUNCE_MS = 3000;
|
|
8
|
+
export const DEFAULT_MAX_FILE_BYTES = 1_000_000;
|
|
9
|
+
export const DEFAULT_INCLUDE: string[] = [];
|
|
10
|
+
export const DEFAULT_EXCLUDE = [
|
|
11
|
+
"**/.git/**",
|
|
12
|
+
"**/.hg/**",
|
|
13
|
+
"**/.svn/**",
|
|
14
|
+
"**/.pi/**",
|
|
15
|
+
"**/node_modules/**",
|
|
16
|
+
"**/vendor/**",
|
|
17
|
+
"**/dist/**",
|
|
18
|
+
"**/build/**",
|
|
19
|
+
"**/coverage/**",
|
|
20
|
+
"**/target/**",
|
|
21
|
+
"**/.cache/**",
|
|
22
|
+
"**/.next/**",
|
|
23
|
+
];
|
|
24
|
+
export const DEFAULT_MARKERS = ["LLM", "PROMPT"];
|
|
25
|
+
export const STORE_NAMESPACE = "llmiterate";
|
|
26
|
+
export const STATE_FILE = "state.json";
|
|
27
|
+
export const APP_STATE_DIR = "pi";
|
|
28
|
+
|
|
29
|
+
export interface LlmiterateConfig {
|
|
30
|
+
enabled: boolean;
|
|
31
|
+
include: string[];
|
|
32
|
+
exclude: string[];
|
|
33
|
+
debounceMs: number;
|
|
34
|
+
maxFileBytes: number;
|
|
35
|
+
markers: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface MarkerPrompt {
|
|
39
|
+
marker: string;
|
|
40
|
+
file: string;
|
|
41
|
+
startLine: number;
|
|
42
|
+
endLine: number;
|
|
43
|
+
prompt: string;
|
|
44
|
+
key: string;
|
|
45
|
+
promptHash: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ProcessedPrompt {
|
|
49
|
+
file: string;
|
|
50
|
+
startLine: number;
|
|
51
|
+
endLine: number;
|
|
52
|
+
promptHash: string;
|
|
53
|
+
promptPreview: string;
|
|
54
|
+
queuedAt: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface LlmiterateState {
|
|
58
|
+
version: 1;
|
|
59
|
+
processed: Record<string, ProcessedPrompt>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const REGEX_CHARS = /[|\\{}()[\]^$+?.]/;
|
|
63
|
+
// File scans call the parser for every saved/scanned file. Cache marker regexes by
|
|
64
|
+
// normalized marker list so steady-state scans do not rebuild/sort/escape regexes.
|
|
65
|
+
const markerRegexCache = new Map<string, RegExp>();
|
|
66
|
+
|
|
67
|
+
export function defaultConfig(): LlmiterateConfig {
|
|
68
|
+
return {
|
|
69
|
+
enabled: true,
|
|
70
|
+
include: [...DEFAULT_INCLUDE],
|
|
71
|
+
exclude: [...DEFAULT_EXCLUDE],
|
|
72
|
+
debounceMs: DEFAULT_DEBOUNCE_MS,
|
|
73
|
+
maxFileBytes: DEFAULT_MAX_FILE_BYTES,
|
|
74
|
+
markers: [...DEFAULT_MARKERS],
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function normalizeConfig(raw: unknown): LlmiterateConfig {
|
|
79
|
+
const fallback = defaultConfig();
|
|
80
|
+
if (!isObject(raw)) return fallback;
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
enabled: typeof raw.enabled === "boolean" ? raw.enabled : fallback.enabled,
|
|
84
|
+
include: stringArray(raw.include, fallback.include),
|
|
85
|
+
exclude: stringArray(raw.exclude, fallback.exclude),
|
|
86
|
+
debounceMs: positiveInt(raw.debounceMs, fallback.debounceMs),
|
|
87
|
+
maxFileBytes: positiveInt(raw.maxFileBytes, fallback.maxFileBytes),
|
|
88
|
+
markers: cleanMarkers(raw.markers, fallback.markers),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function loadConfig(cwd: string): LlmiterateConfig {
|
|
93
|
+
const global = readSettingsNamespace(path.join(getAgentDir(), "settings.json"));
|
|
94
|
+
const project = readSettingsNamespace(path.join(cwd, ".pi", "settings.json"));
|
|
95
|
+
return normalizeConfig({ ...global, ...project });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function readSettingsNamespace(settingsPath: string): Record<string, unknown> {
|
|
99
|
+
try {
|
|
100
|
+
const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
|
|
101
|
+
return isObject(parsed) && isObject(parsed.llmiterate) ? parsed.llmiterate : {};
|
|
102
|
+
} catch {
|
|
103
|
+
return {};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function projectStoreDir(root: string): string {
|
|
108
|
+
const storeDir = path.join(platformStateDir(), STORE_NAMESPACE, hashProjectRoot(root));
|
|
109
|
+
migrateLegacyProjectStore(root, storeDir);
|
|
110
|
+
return storeDir;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function platformStateDir(
|
|
114
|
+
platform: NodeJS.Platform = process.platform,
|
|
115
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
116
|
+
home = os.homedir(),
|
|
117
|
+
): string {
|
|
118
|
+
const paths = platform === "win32" ? path.win32 : path.posix;
|
|
119
|
+
|
|
120
|
+
if (platform === "win32") {
|
|
121
|
+
return paths.join(env.LOCALAPPDATA ?? paths.join(home, "AppData", "Local"), APP_STATE_DIR);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (env.XDG_STATE_HOME) return paths.join(env.XDG_STATE_HOME, APP_STATE_DIR);
|
|
125
|
+
|
|
126
|
+
if (platform === "darwin") {
|
|
127
|
+
return paths.join(home, "Library", "Application Support", APP_STATE_DIR);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return paths.join(home, ".local", "state", APP_STATE_DIR);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function legacyProjectStoreDir(root: string): string {
|
|
134
|
+
return path.join(getAgentDir(), STORE_NAMESPACE, hashProjectRoot(root));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function migrateLegacyProjectStore(root: string, storeDir: string): void {
|
|
138
|
+
const legacy = legacyProjectStoreDir(root);
|
|
139
|
+
if (legacy === storeDir || fs.existsSync(storeDir) || !fs.existsSync(legacy)) return;
|
|
140
|
+
|
|
141
|
+
fs.mkdirSync(path.dirname(storeDir), { recursive: true });
|
|
142
|
+
fs.cpSync(legacy, storeDir, {
|
|
143
|
+
recursive: true,
|
|
144
|
+
filter: (source) => path.basename(source) !== "lock",
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function hashProjectRoot(root: string): string {
|
|
149
|
+
return createHash("sha256").update(root).digest("hex");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function loadState(storeDir: string): LlmiterateState {
|
|
153
|
+
try {
|
|
154
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(storeDir, STATE_FILE), "utf-8"));
|
|
155
|
+
if (!isObject(parsed) || parsed.version !== 1 || !isObject(parsed.processed)) {
|
|
156
|
+
return emptyState();
|
|
157
|
+
}
|
|
158
|
+
return { version: 1, processed: parseProcessedPrompts(parsed.processed) };
|
|
159
|
+
} catch {
|
|
160
|
+
return emptyState();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function saveState(storeDir: string, state: LlmiterateState): void {
|
|
165
|
+
fs.mkdirSync(storeDir, { recursive: true });
|
|
166
|
+
fs.writeFileSync(path.join(storeDir, STATE_FILE), JSON.stringify(state, null, 2) + "\n", "utf-8");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function emptyState(): LlmiterateState {
|
|
170
|
+
return { version: 1, processed: {} };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function parseProcessedPrompts(raw: Record<string, unknown>): Record<string, ProcessedPrompt> {
|
|
174
|
+
const processed: Record<string, ProcessedPrompt> = {};
|
|
175
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
176
|
+
if (isProcessedPrompt(value)) processed[key] = value;
|
|
177
|
+
}
|
|
178
|
+
return processed;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function isProcessedPrompt(value: unknown): value is ProcessedPrompt {
|
|
182
|
+
if (!isObject(value)) return false;
|
|
183
|
+
const keys = Object.keys(value).sort();
|
|
184
|
+
if (keys.join("\0") !== "endLine\0file\0promptHash\0promptPreview\0queuedAt\0startLine") return false;
|
|
185
|
+
return typeof value.file === "string"
|
|
186
|
+
&& typeof value.startLine === "number"
|
|
187
|
+
&& typeof value.endLine === "number"
|
|
188
|
+
&& typeof value.promptHash === "string"
|
|
189
|
+
&& typeof value.promptPreview === "string"
|
|
190
|
+
&& typeof value.queuedAt === "number";
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function parseMarkerPrompts(file: string, text: string, markers = DEFAULT_MARKERS): MarkerPrompt[] {
|
|
194
|
+
const markerLine = markerRegex(markers);
|
|
195
|
+
const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
|
196
|
+
const prompts: MarkerPrompt[] = [];
|
|
197
|
+
const markdown = isMarkdownPath(file);
|
|
198
|
+
let markdownFence: "`" | "~" | undefined;
|
|
199
|
+
|
|
200
|
+
for (let i = 0; i < lines.length; i++) {
|
|
201
|
+
const rawLine = lines[i] ?? "";
|
|
202
|
+
const fence = markdownFenceDelimiter(markdown, rawLine);
|
|
203
|
+
if (fence) {
|
|
204
|
+
markdownFence = markdownFence === fence ? undefined : markdownFence ?? fence;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (markdownFence) continue;
|
|
208
|
+
|
|
209
|
+
const first = markerLine.exec(rawLine.trimStart());
|
|
210
|
+
if (!first) continue;
|
|
211
|
+
|
|
212
|
+
const marker = first[1] ?? "LLM";
|
|
213
|
+
const firstPromptLine = (first[2] ?? "").trim();
|
|
214
|
+
if (!firstPromptLine || isCodeAssignmentPrompt(firstPromptLine)) continue;
|
|
215
|
+
|
|
216
|
+
const startLine = i + 1;
|
|
217
|
+
const body = [firstPromptLine];
|
|
218
|
+
let endLine = startLine;
|
|
219
|
+
|
|
220
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
221
|
+
const nextRawLine = lines[j] ?? "";
|
|
222
|
+
if (markdownFenceDelimiter(markdown, nextRawLine)) break;
|
|
223
|
+
const next = markerLine.exec(nextRawLine.trimStart());
|
|
224
|
+
if (!next || next[1] !== marker) break;
|
|
225
|
+
const promptLine = (next[2] ?? "").trim();
|
|
226
|
+
if (isCodeAssignmentPrompt(promptLine)) break;
|
|
227
|
+
if (promptLine) body.push(promptLine);
|
|
228
|
+
endLine = j + 1;
|
|
229
|
+
i = j;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const prompt = body.join("\n").trim();
|
|
233
|
+
if (prompt) prompts.push(makePrompt(file, marker, startLine, endLine, prompt));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return prompts;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function makePrompt(file: string, marker: string, startLine: number, endLine: number, prompt: string): MarkerPrompt {
|
|
240
|
+
const promptHash = hashString(prompt);
|
|
241
|
+
return {
|
|
242
|
+
marker,
|
|
243
|
+
file,
|
|
244
|
+
startLine,
|
|
245
|
+
endLine,
|
|
246
|
+
prompt,
|
|
247
|
+
promptHash,
|
|
248
|
+
key: `${file}:${startLine}:${promptHash}`,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function buildAgentPrompt(block: MarkerPrompt): string {
|
|
253
|
+
return [
|
|
254
|
+
`llmiterate request from @${block.file}:${block.startLine}`,
|
|
255
|
+
"",
|
|
256
|
+
"Prompt marker contents:",
|
|
257
|
+
"```text",
|
|
258
|
+
block.prompt,
|
|
259
|
+
"```",
|
|
260
|
+
"",
|
|
261
|
+
"Instructions:",
|
|
262
|
+
`- Work in/around @${block.file}.`,
|
|
263
|
+
`- Marker span to replace: ${block.file}:${block.startLine}-${block.endLine}.`,
|
|
264
|
+
"- Treat the marker line(s) as an inline prompt/TODO, not program code.",
|
|
265
|
+
"- Replace the whole marker span with the requested implementation or code change.",
|
|
266
|
+
"- Do not leave the marker line(s) behind unless impossible; explain if impossible.",
|
|
267
|
+
].join("\n");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export type CompiledGlobSegment = "**" | RegExp;
|
|
271
|
+
|
|
272
|
+
export interface CompiledPathFilter {
|
|
273
|
+
include: RegExp[];
|
|
274
|
+
includeDirPatterns: CompiledGlobSegment[][];
|
|
275
|
+
exclude: RegExp[];
|
|
276
|
+
excludeRootDirs: Set<string>;
|
|
277
|
+
excludePrefixes: string[];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function compilePathFilter(config: Pick<LlmiterateConfig, "include" | "exclude">): CompiledPathFilter {
|
|
281
|
+
// Path filtering is the hottest repository-scan path. Split cheap "dir/**"
|
|
282
|
+
// excludes into root-dir Set lookups / prefix checks before falling back to regex.
|
|
283
|
+
const includeDirPatterns = config.include.map(compileGlobSegments);
|
|
284
|
+
const excludeRootDirs = new Set<string>();
|
|
285
|
+
const excludePrefixes: string[] = [];
|
|
286
|
+
const exclude: RegExp[] = [];
|
|
287
|
+
|
|
288
|
+
for (const glob of config.exclude) {
|
|
289
|
+
const prefix = simpleGlobstarPrefix(glob);
|
|
290
|
+
if (!prefix) {
|
|
291
|
+
exclude.push(globToRegExp(glob));
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const rootDir = rootDirPrefix(prefix);
|
|
296
|
+
if (rootDir) excludeRootDirs.add(rootDir);
|
|
297
|
+
else excludePrefixes.push(prefix);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return { include: config.include.map(globToRegExp), includeDirPatterns, exclude, excludeRootDirs, excludePrefixes };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function shouldIncludePath(relPath: string, config: Pick<LlmiterateConfig, "include" | "exclude"> | CompiledPathFilter): boolean {
|
|
304
|
+
const filter = isCompiledPathFilter(config) ? config : compilePathFilter(config);
|
|
305
|
+
return shouldIncludeCompiledPath(relPath, filter);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function shouldIncludeCompiledPath(relPath: string, filter: CompiledPathFilter): boolean {
|
|
309
|
+
const rel = toSlash(relPath);
|
|
310
|
+
if (isExcludedPath(rel, filter)) return false;
|
|
311
|
+
return filter.include.some((pattern) => pattern.test(rel));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function isExcludedPath(relPath: string, filter: CompiledPathFilter): boolean {
|
|
315
|
+
const rel = toSlash(relPath);
|
|
316
|
+
return filter.excludeRootDirs.has(firstPathSegment(rel))
|
|
317
|
+
|| filter.excludePrefixes.some((prefix) => rel.startsWith(prefix))
|
|
318
|
+
|| filter.exclude.some((pattern) => pattern.test(rel));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function shouldWatchDirectory(relPath: string, filter: CompiledPathFilter): boolean {
|
|
322
|
+
const rel = toSlash(relPath);
|
|
323
|
+
if (rel && (isExcludedPath(rel, filter) || isExcludedPath(`${rel}/`, filter))) return false;
|
|
324
|
+
const segments = rel ? rel.split("/") : [];
|
|
325
|
+
return filter.includeDirPatterns.some((include) => includeCanMatchUnderDir(include, segments));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function globToRegExp(pattern: string): RegExp {
|
|
329
|
+
const glob = toSlash(pattern);
|
|
330
|
+
let s = "^";
|
|
331
|
+
for (let i = 0; i < glob.length;) {
|
|
332
|
+
const c = glob[i];
|
|
333
|
+
if (c === "*") {
|
|
334
|
+
if (glob[i + 1] === "*") {
|
|
335
|
+
i += 2;
|
|
336
|
+
if (glob[i] === "/") {
|
|
337
|
+
i++;
|
|
338
|
+
s += "(?:.*/)?";
|
|
339
|
+
} else {
|
|
340
|
+
s += ".*";
|
|
341
|
+
}
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
s += "[^/]*";
|
|
345
|
+
i++;
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
if (c === "?") {
|
|
349
|
+
s += "[^/]";
|
|
350
|
+
i++;
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (c === "{") {
|
|
354
|
+
const end = glob.indexOf("}", i + 1);
|
|
355
|
+
if (end !== -1) {
|
|
356
|
+
const parts = glob.slice(i + 1, end).split(",").map((p) => p.trim()).filter(Boolean).map(escapeRegExp);
|
|
357
|
+
if (parts.length) {
|
|
358
|
+
s += `(?:${parts.join("|")})`;
|
|
359
|
+
i = end + 1;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
s += REGEX_CHARS.test(c) ? `\\${c}` : c;
|
|
365
|
+
i++;
|
|
366
|
+
}
|
|
367
|
+
return new RegExp(`${s}$`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export function toSlash(s: string): string {
|
|
371
|
+
// Hot path during repository scans. Avoid split/join allocation when already normalized.
|
|
372
|
+
return s.includes("\\") ? s.replace(/\\/g, "/") : s;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function includeCanMatchUnderDir(pattern: CompiledGlobSegment[], dir: string[]): boolean {
|
|
376
|
+
const memo = new Map<string, boolean>();
|
|
377
|
+
const visit = (patternIndex: number, dirIndex: number): boolean => {
|
|
378
|
+
const key = `${patternIndex}:${dirIndex}`;
|
|
379
|
+
const cached = memo.get(key);
|
|
380
|
+
if (cached !== undefined) return cached;
|
|
381
|
+
|
|
382
|
+
let result: boolean;
|
|
383
|
+
if (dirIndex >= dir.length) {
|
|
384
|
+
result = patternIndex < pattern.length;
|
|
385
|
+
} else if (patternIndex >= pattern.length) {
|
|
386
|
+
result = false;
|
|
387
|
+
} else {
|
|
388
|
+
const segment = pattern[patternIndex]!;
|
|
389
|
+
result = segment === "**"
|
|
390
|
+
? visit(patternIndex + 1, dirIndex) || visit(patternIndex, dirIndex + 1)
|
|
391
|
+
: segment.test(dir[dirIndex]!) && visit(patternIndex + 1, dirIndex + 1);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
memo.set(key, result);
|
|
395
|
+
return result;
|
|
396
|
+
};
|
|
397
|
+
return visit(0, 0);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function compileGlobSegments(pattern: string): CompiledGlobSegment[] {
|
|
401
|
+
return toSlash(pattern).split("/").filter(Boolean).map((segment) => segment === "**" ? "**" : globToRegExp(segment));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function hashString(input: string): string {
|
|
405
|
+
let hash = 0x811c9dc5;
|
|
406
|
+
for (let i = 0; i < input.length; i++) {
|
|
407
|
+
hash ^= input.charCodeAt(i);
|
|
408
|
+
hash = Math.imul(hash, 0x01000193);
|
|
409
|
+
}
|
|
410
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function previewText(text: string, maxChars: number): string {
|
|
414
|
+
const oneLine = text.replace(/\s+/g, " ").trim();
|
|
415
|
+
return oneLine.length > maxChars ? `${oneLine.slice(0, Math.max(0, maxChars - 1))}…` : oneLine;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function markerRegex(markers: string[]): RegExp {
|
|
419
|
+
const cleaned = cleanMarkers(markers, DEFAULT_MARKERS).sort((a, b) => b.length - a.length);
|
|
420
|
+
const cacheKey = cleaned.join("\0");
|
|
421
|
+
const cached = markerRegexCache.get(cacheKey);
|
|
422
|
+
if (cached) return cached;
|
|
423
|
+
|
|
424
|
+
const markerAlternatives = cleaned.map(escapeRegExp).join("|");
|
|
425
|
+
const compiled = new RegExp(`^(${markerAlternatives})(?:\\s+(.*))?$`);
|
|
426
|
+
markerRegexCache.set(cacheKey, compiled);
|
|
427
|
+
return compiled;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function markdownFenceDelimiter(markdown: boolean, line: string): "`" | "~" | undefined {
|
|
431
|
+
if (!markdown) return undefined;
|
|
432
|
+
const trimmed = line.trimStart();
|
|
433
|
+
if (/^```/.test(trimmed)) return "`";
|
|
434
|
+
if (/^~~~/.test(trimmed)) return "~";
|
|
435
|
+
return undefined;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function isMarkdownPath(file: string): boolean {
|
|
439
|
+
return /(?:^|\.)(?:md|mdx|markdown)$/i.test(file);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function isCodeAssignmentPrompt(prompt: string): boolean {
|
|
443
|
+
return prompt.startsWith("=");
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function simpleGlobstarPrefix(glob: string): string | undefined {
|
|
447
|
+
const normalized = toSlash(glob);
|
|
448
|
+
if (!normalized.endsWith("/**")) return undefined;
|
|
449
|
+
const prefix = normalized.slice(0, -2);
|
|
450
|
+
return /[*?{[]/.test(prefix) ? undefined : prefix;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function rootDirPrefix(prefix: string): string | undefined {
|
|
454
|
+
const dir = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
455
|
+
return dir && !dir.includes("/") ? dir : undefined;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function firstPathSegment(path: string): string {
|
|
459
|
+
const slash = path.indexOf("/");
|
|
460
|
+
return slash === -1 ? path : path.slice(0, slash);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function escapeRegExp(s: string): string {
|
|
464
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
468
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function stringArray(value: unknown, fallback: string[]): string[] {
|
|
472
|
+
return Array.isArray(value) && value.every((v) => typeof v === "string" && v.trim())
|
|
473
|
+
? value.map((v) => v.trim())
|
|
474
|
+
: [...fallback];
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function positiveInt(value: unknown, fallback: number): number {
|
|
478
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function cleanMarkers(value: unknown, fallback: string[]): string[] {
|
|
482
|
+
const raw = stringArray(value, fallback);
|
|
483
|
+
const cleaned = raw.map((m) => m.trim()).filter((m) => m.length > 0 && !/[\r\n]/.test(m));
|
|
484
|
+
return cleaned.length ? cleaned : [...fallback];
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function isCompiledPathFilter(value: Pick<LlmiterateConfig, "include" | "exclude"> | CompiledPathFilter): value is CompiledPathFilter {
|
|
488
|
+
return "includeDirPatterns" in value;
|
|
489
|
+
}
|