@vanillagreen/pi-qol 2.2.1 → 2.3.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.
@@ -1,6 +1,38 @@
1
- import { closeSync, openSync, readSync } from "node:fs";
1
+ import { closeSync, createReadStream, openSync, readSync } from "node:fs";
2
2
  import { StringDecoder } from "node:string_decoder";
3
3
 
4
+ /** Stream records off the input path. Index reads skip oversized records;
5
+ * an action can read a complete prompt and stop at its selected entry. */
6
+ export async function forEachSessionJsonlLineAsync(sessionPath: string, onLine: (line: string) => unknown, signal: AbortSignal, options?: { maxLineChars: number }): Promise<void> {
7
+ const stream = createReadStream(sessionPath, { signal, highWaterMark: 64 * 1024 });
8
+ const decoder = new StringDecoder("utf8");
9
+ const maxLineChars = options?.maxLineChars ?? 2 * 1024 * 1024;
10
+ let pending = "";
11
+ let skipping = false;
12
+ try {
13
+ for await (const chunk of stream) {
14
+ signal.throwIfAborted();
15
+ const text = decoder.write(chunk);
16
+ let start = 0;
17
+ for (;;) {
18
+ const end = text.indexOf("\n", start);
19
+ const part = text.slice(start, end < 0 ? undefined : end);
20
+ if (!skipping && pending.length + part.length <= maxLineChars) pending += part;
21
+ else { pending = ""; skipping = true; }
22
+ if (end < 0) break;
23
+ if (!skipping && onLine(pending.endsWith("\r") ? pending.slice(0, -1) : pending) === false) return;
24
+ pending = "";
25
+ skipping = false;
26
+ start = end + 1;
27
+ }
28
+ }
29
+ pending += decoder.end();
30
+ if (!skipping && pending.length > 0) onLine(pending.endsWith("\r") ? pending.slice(0, -1) : pending);
31
+ } finally {
32
+ stream.destroy();
33
+ }
34
+ }
35
+
4
36
  export function forEachSessionJsonlLine(sessionPath: string, onLine: (line: string) => void, chunkSize = 64 * 1024): void {
5
37
  const fd = openSync(sessionPath, "r");
6
38
  const buffer = Buffer.allocUnsafe(Math.max(1, Math.floor(chunkSize)));
@@ -0,0 +1,12 @@
1
+ import { parentPort } from "node:worker_threads";
2
+
3
+ // The parent owns the deadline and terminates this worker even when exec
4
+ // never returns. Neither prompt matching nor snippet matching runs in Pi.
5
+ parentPort.on("message", ({ source, texts }) => {
6
+ const regex = new RegExp(source, "i");
7
+ parentPort.postMessage({ status: "matched", matches: texts.map((text) => {
8
+ const match = regex.exec(text);
9
+ return match ? { index: match.index, length: match[0].length } : null;
10
+ }) });
11
+ });
12
+ parentPort.postMessage({ status: "ready" });
@@ -0,0 +1,49 @@
1
+ import { Worker } from "node:worker_threads";
2
+
3
+ export interface SessionRegexMatch { index: number; length: number }
4
+ type WorkerReply = { status: "ready" } | { status: "matched"; matches: Array<SessionRegexMatch | null> };
5
+
6
+ /** Run one query's matching in a disposable worker. User-entered regex can
7
+ * backtrack forever, so the owner terminates it rather than timing exec. */
8
+ export function sessionRegexMatches(source: string, texts: string[], signal: AbortSignal): Promise<Array<SessionRegexMatch | null>> {
9
+ signal.throwIfAborted();
10
+ return new Promise((resolve, reject) => {
11
+ const worker = new Worker(new URL("./regex-worker.mjs", import.meta.url), { execArgv: [], env: {} });
12
+ let settled = false;
13
+ // Startup is separate from the 25 ms execution budget.
14
+ let timer = setTimeout(() => finish(new Error("SESSION_SEARCH_REGEX_DEADLINE: worker startup")), 1000);
15
+ const finish = (error?: Error, matches?: Array<SessionRegexMatch | null>) => {
16
+ if (settled) return;
17
+ settled = true;
18
+ clearTimeout(timer);
19
+ signal.removeEventListener("abort", abort);
20
+ worker.removeAllListeners("message");
21
+ // A runtime can defer thread exit until native regex execution returns.
22
+ // Request cleanup without making input wait for that exit.
23
+ worker.unref();
24
+ void worker.terminate().catch((cleanupError: unknown) => {
25
+ console.error("Session regex worker cleanup failed:", cleanupError);
26
+ });
27
+ if (error) reject(error);
28
+ else resolve(matches!);
29
+ };
30
+ const abort = () => finish(new Error("Session search cancelled"));
31
+ signal.addEventListener("abort", abort, { once: true });
32
+ worker.on("message", (reply: WorkerReply) => {
33
+ if (settled) return;
34
+ switch (reply.status) {
35
+ case "ready":
36
+ clearTimeout(timer);
37
+ timer = setTimeout(() => finish(new Error("SESSION_SEARCH_REGEX_DEADLINE: 25 ms")), 25);
38
+ worker.postMessage({ source, texts });
39
+ break;
40
+ case "matched": finish(undefined, reply.matches); break;
41
+ default: { const unexpected: never = reply; finish(new Error(`Unknown regex worker reply: ${unexpected}`)); }
42
+ }
43
+ });
44
+ worker.once("error", (error) => finish(error));
45
+ worker.once("exit", (code) => {
46
+ if (!settled) finish(new Error(`Session regex worker exited before results: ${code}`));
47
+ });
48
+ });
49
+ }
@@ -1,10 +1,9 @@
1
- import {
2
- DEFAULT_SESSION_SEARCH_LIMIT,
3
- DEFAULT_SESSION_SEARCH_PREVIEW_SNIPPETS,
4
- } from "../constants.js";
1
+ import { setImmediate as yieldToInput } from "node:timers/promises";
2
+ import { DEFAULT_SESSION_SEARCH_LIMIT } from "../constants.js";
5
3
  import { settingNumber } from "../settings.js";
6
4
  import { stringifyError } from "../util.js";
7
5
  import { sessionDisplayName, sessionResumeTitle, userMessagesForResult } from "./cache.js";
6
+ import { sessionRegexMatches, type SessionRegexMatch } from "./regex.js";
8
7
  import type {
9
8
  QolParsedSessionQuery,
10
9
  QolSessionSearchHit,
@@ -13,10 +12,6 @@ import type {
13
12
  QolSessionUserMessage,
14
13
  } from "./types.js";
15
14
 
16
- export function sessionSearchText(session: QolSessionSearchSession): string {
17
- return [session.id, session.name ?? "", session.cwd, session.path, session.firstMessage, session.allMessagesText].join("\n");
18
- }
19
-
20
15
  export function normalizeSearchText(text: string): string {
21
16
  return text.toLowerCase().replace(/\s+/g, " ").trim();
22
17
  }
@@ -33,161 +28,57 @@ export function parseSessionSearchQuery(query: string): QolParsedSessionQuery {
33
28
  return { error: stringifyError(error), mode: "regex", tokens: [] };
34
29
  }
35
30
  }
36
-
37
31
  const tokens: QolParsedSessionQuery["tokens"] = [];
38
32
  let buffer = "";
39
33
  let inQuote = false;
40
- let unclosed = false;
41
34
  const flush = (kind: "fuzzy" | "phrase") => {
42
35
  const value = buffer.trim();
43
36
  buffer = "";
44
37
  if (value) tokens.push({ kind, value });
45
38
  };
46
-
47
- for (let i = 0; i < trimmed.length; i++) {
48
- const char = trimmed[i]!;
39
+ for (const char of trimmed) {
49
40
  if (char === '"') {
50
- if (inQuote) {
51
- flush("phrase");
52
- inQuote = false;
53
- } else {
54
- flush("fuzzy");
55
- inQuote = true;
56
- }
57
- continue;
58
- }
59
- if (!inQuote && /\s/.test(char)) {
60
- flush("fuzzy");
61
- continue;
62
- }
63
- buffer += char;
41
+ flush(inQuote ? "phrase" : "fuzzy");
42
+ inQuote = !inQuote;
43
+ } else if (!inQuote && /\s/.test(char)) flush("fuzzy");
44
+ else buffer += char;
64
45
  }
65
- if (inQuote) unclosed = true;
66
- if (unclosed) {
67
- return {
68
- mode: "tokens",
69
- tokens: trimmed.split(/\s+/).filter(Boolean).map((value) => ({ kind: "fuzzy", value })),
70
- };
71
- }
72
- flush(inQuote ? "phrase" : "fuzzy");
46
+ if (inQuote) return { mode: "tokens", tokens: trimmed.split(/\s+/).filter(Boolean).map((value) => ({ kind: "fuzzy", value })) };
47
+ flush("fuzzy");
73
48
  return { mode: "tokens", tokens };
74
49
  }
75
50
 
76
- function searchWordLengthPenalty(word: string, query: string): number {
77
- return Math.max(0, word.length - query.length);
78
- }
79
-
80
51
  export function searchStringScore(needle: string, haystack: string): number | undefined {
81
52
  const query = normalizeSearchText(needle);
82
53
  const text = normalizeSearchText(haystack);
83
54
  if (!query) return 0;
84
55
  let best: number | undefined;
85
- const record = (score: number) => {
86
- best = best === undefined ? score : Math.min(best, score);
87
- };
56
+ const record = (score: number) => { best = best === undefined ? score : Math.min(best, score); };
88
57
  if (/^[a-z0-9_]+$/i.test(query)) {
89
58
  for (const match of text.matchAll(/[a-z0-9_]+/gi)) {
90
59
  const word = match[0].toLowerCase();
60
+ const penalty = Math.max(0, word.length - query.length);
91
61
  if (word === query) record(0);
92
- else if (word.startsWith(query)) record(10 + searchWordLengthPenalty(word, query));
93
- else if (word.includes(query)) record(100 + searchWordLengthPenalty(word, query));
62
+ else if (word.startsWith(query)) record(10 + penalty);
63
+ else if (word.includes(query)) record(100 + penalty);
94
64
  }
95
65
  return best;
96
66
  }
97
67
  return text.includes(query) ? 0 : undefined;
98
68
  }
99
69
 
100
- export function matchSessionSearch(session: QolSessionSearchSession, parsed: QolParsedSessionQuery): { matches: boolean; score: number } {
101
- const text = sessionSearchText(session);
102
- if (parsed.mode === "regex") {
103
- if (!parsed.regex) return { matches: false, score: 0 };
104
- const index = text.search(parsed.regex);
105
- return index < 0 ? { matches: false, score: 0 } : { matches: true, score: 0 };
106
- }
107
- if (parsed.tokens.length === 0) return { matches: true, score: 0 };
108
- let score = 0;
109
- for (const token of parsed.tokens) {
110
- const tokenScore = searchStringScore(token.value, text);
111
- if (tokenScore === undefined) return { matches: false, score: 0 };
112
- score += tokenScore;
113
- }
114
- return { matches: true, score };
115
- }
116
-
117
- function sessionSnippetSource(session: QolSessionSearchSession): string {
118
- return (session.allMessagesText || session.firstMessage || "").replace(/\s+/g, " ").trim();
119
- }
120
-
121
70
  function snippetAround(text: string, start: number, length: number, width: number, lead = Math.floor(width / 3)): string {
122
- const safeLead = Math.max(0, Math.min(width, lead));
123
- const safeStart = Math.max(0, start - safeLead);
71
+ const safeStart = Math.max(0, start - Math.max(0, Math.min(width, lead)));
124
72
  const safeEnd = Math.min(text.length, Math.max(start + length, safeStart + width));
125
- const prefix = safeStart > 0 ? "…" : "";
126
- const suffix = safeEnd < text.length ? "…" : "";
127
- return `${prefix}${text.slice(safeStart, safeEnd)}${suffix}`;
128
- }
129
-
130
- export function buildSessionSnippets(session: QolSessionSearchSession, parsed: QolParsedSessionQuery, limit: number): string[] {
131
- const source = sessionSnippetSource(session);
132
- if (!source) return [];
133
- if (parsed.mode === "regex" && parsed.regex) {
134
- parsed.regex.lastIndex = 0;
135
- const match = parsed.regex.exec(source);
136
- return match ? [snippetAround(source, match.index, match[0].length, 220)] : [];
137
- }
138
- if (parsed.tokens.length === 0) return [source.slice(0, 220)];
139
- const snippets: string[] = [];
140
- const lower = source.toLowerCase();
141
- const seen = new Set<string>();
142
- for (const token of parsed.tokens) {
143
- const value = normalizeSearchText(token.value);
144
- if (!value) continue;
145
- const index = lower.indexOf(value.toLowerCase());
146
- if (index < 0) continue;
147
- const snippet = snippetAround(source, index, value.length, 220);
148
- const key = snippet.toLowerCase();
149
- if (!seen.has(key)) {
150
- seen.add(key);
151
- snippets.push(snippet);
152
- }
153
- if (snippets.length >= limit) break;
154
- }
155
- return snippets.length > 0 ? snippets : [source.slice(0, 220)];
156
- }
157
-
158
- export function searchQolSessions(sessions: QolSessionSearchSession[], query: string, cwd: string): QolSessionSearchResult[] {
159
- const limit = Math.max(1, Math.floor(settingNumber("sessionSearch.resultLimit", DEFAULT_SESSION_SEARCH_LIMIT, cwd)));
160
- const snippetLimit = Math.max(1, Math.floor(settingNumber("sessionSearch.previewSnippets", DEFAULT_SESSION_SEARCH_PREVIEW_SNIPPETS, cwd)));
161
- const parsed = parseSessionSearchQuery(query);
162
- if (parsed.error) return [];
163
- if (!query.trim()) {
164
- return sessions
165
- .slice()
166
- .sort((a, b) => b.modified.getTime() - a.modified.getTime())
167
- .slice(0, limit)
168
- .map((session) => ({ ...session, rank: 0, snippets: buildSessionSnippets(session, { mode: "tokens", tokens: [] }, 1) }));
169
- }
170
- const results: QolSessionSearchResult[] = [];
171
- for (const session of sessions) {
172
- const match = matchSessionSearch(session, parsed);
173
- if (!match.matches) continue;
174
- results.push({ ...session, rank: match.score, snippets: buildSessionSnippets(session, parsed, snippetLimit) });
175
- }
176
- results.sort((a, b) => a.rank - b.rank || b.modified.getTime() - a.modified.getTime());
177
- return results.slice(0, limit);
73
+ return `${safeStart > 0 ? "…" : ""}${text.slice(safeStart, safeEnd)}${safeEnd < text.length ? "…" : ""}`;
178
74
  }
179
75
 
180
76
  export function resultFromSession(session: QolSessionSearchSession, rank = 0, snippets: string[] = []): QolSessionSearchResult {
181
77
  return { ...session, rank, snippets };
182
78
  }
183
79
 
184
- function matchTextSearch(text: string, parsed: QolParsedSessionQuery): { matches: boolean; score: number } {
185
- if (parsed.mode === "regex") {
186
- if (!parsed.regex) return { matches: false, score: 0 };
187
- const index = text.search(parsed.regex);
188
- return index < 0 ? { matches: false, score: 0 } : { matches: true, score: 0 };
189
- }
190
- if (parsed.tokens.length === 0) return { matches: true, score: 0 };
80
+ function matchTextSearch(text: string, parsed: QolParsedSessionQuery, regexMatches: Map<string, SessionRegexMatch | null>): { matches: boolean; score: number } {
81
+ if (parsed.mode === "regex") return { matches: regexMatches.get(text) != null, score: 0 };
191
82
  let score = 0;
192
83
  for (const token of parsed.tokens) {
193
84
  const tokenScore = searchStringScore(token.value, text);
@@ -201,24 +92,17 @@ function sessionTitleSearchText(session: QolSessionSearchSession): string {
201
92
  return [session.name ?? "", sessionResumeTitle(session), sessionDisplayName(session)].join("\n");
202
93
  }
203
94
 
204
- function matchPromptSearch(_session: QolSessionSearchSession, message: QolSessionUserMessage, parsed: QolParsedSessionQuery): { matches: boolean; score: number } {
205
- return matchTextSearch(message.text, parsed);
206
- }
207
-
208
- export function buildPromptSnippet(message: QolSessionUserMessage, parsed: QolParsedSessionQuery): string {
95
+ export function buildPromptSnippet(message: QolSessionUserMessage, parsed: QolParsedSessionQuery, regexMatch?: SessionRegexMatch | null): string {
96
+ // Worker offsets refer to the original prompt, before whitespace collapses.
97
+ if (parsed.mode === "regex" && regexMatch) return snippetAround(message.text, regexMatch.index, Math.min(regexMatch.length, 160), 160, 24).replace(/\s+/g, " ").trim();
209
98
  const source = message.text.replace(/\s+/g, " ").trim();
210
99
  if (!source) return "";
211
- if (parsed.mode === "regex" && parsed.regex) {
212
- parsed.regex.lastIndex = 0;
213
- const match = parsed.regex.exec(source);
214
- return match ? snippetAround(source, match.index, match[0].length, 160, 24) : source.slice(0, 160);
215
- }
216
- if (parsed.tokens.length === 0) return source.slice(0, 160);
100
+ if (parsed.mode === "regex") return source.slice(0, 160);
217
101
  const lower = source.toLowerCase();
218
102
  for (const token of parsed.tokens) {
219
103
  const value = normalizeSearchText(token.value);
220
104
  if (!value) continue;
221
- const index = lower.indexOf(value.toLowerCase());
105
+ const index = lower.indexOf(value);
222
106
  if (index >= 0) return snippetAround(source, index, value.length, 160, 24);
223
107
  }
224
108
  return source.slice(0, 160);
@@ -235,48 +119,58 @@ function compareSessionSearchHitsRecent(a: QolSessionSearchHit, b: QolSessionSea
235
119
  || b.message.index - a.message.index;
236
120
  }
237
121
 
238
- export function searchQolSessionHits(sessions: QolSessionSearchSession[], query: string, cwd: string): QolSessionSearchHit[] {
122
+ /** Search only prepared prompts. Yield between batches, cancel obsolete
123
+ * queries, and run all regex matching in a deadline-owned worker. */
124
+ export async function searchQolSessionHits(sessions: QolSessionSearchSession[], query: string, cwd: string, signal: AbortSignal): Promise<QolSessionSearchHit[]> {
125
+ signal.throwIfAborted();
239
126
  const limit = Math.max(1, Math.floor(settingNumber("sessionSearch.resultLimit", DEFAULT_SESSION_SEARCH_LIMIT, cwd)));
240
127
  const parsed = parseSessionSearchQuery(query);
241
- if (parsed.error) return [];
242
- const hits: QolSessionSearchHit[] = [];
243
- if (!query.trim()) {
244
- for (const session of sessions) {
245
- const result = resultFromSession(session);
246
- for (const message of userMessagesForResult(result)) {
247
- const snippet = buildPromptSnippet(message, parsed);
248
- hits.push({ message, rank: 0, result: resultFromSession(session, 0, [snippet]), snippet });
249
- }
250
- }
251
- hits.sort(compareSessionSearchHitsRecent);
252
- return hits.slice(0, limit);
128
+ if (parsed.error) throw new Error(parsed.error);
129
+ const regexMatches = new Map<string, SessionRegexMatch | null>();
130
+ if (parsed.mode === "regex" && parsed.regex) {
131
+ const texts = [...new Set(sessions.flatMap((session) => [sessionTitleSearchText(session), ...userMessagesForResult(resultFromSession(session)).map((message) => message.text)]))];
132
+ const matches = await sessionRegexMatches(parsed.regex.source, texts, signal);
133
+ texts.forEach((text, index) => regexMatches.set(text, matches[index]!));
253
134
  }
135
+ const hits: QolSessionSearchHit[] = [];
136
+ let batchStarted = performance.now();
254
137
  for (const session of sessions) {
138
+ signal.throwIfAborted();
139
+ const messages = userMessagesForResult(resultFromSession(session));
255
140
  let addedPromptHit = false;
256
- for (const message of userMessagesForResult(resultFromSession(session))) {
257
- const match = matchPromptSearch(session, message, parsed);
258
- if (!match.matches) continue;
259
- const snippet = buildPromptSnippet(message, parsed);
260
- hits.push({ message, rank: match.score, result: resultFromSession(session, match.score, [snippet]), snippet });
261
- addedPromptHit = true;
141
+ for (const message of messages) {
142
+ const match = matchTextSearch(message.text, parsed, regexMatches);
143
+ if (match.matches) {
144
+ const snippet = buildPromptSnippet(message, parsed, regexMatches.get(message.text));
145
+ hits.push({ message, rank: match.score, result: resultFromSession(session, match.score, [snippet]), snippet });
146
+ addedPromptHit = true;
147
+ }
148
+ if (performance.now() - batchStarted >= 4) {
149
+ await yieldToInput(undefined, { signal });
150
+ batchStarted = performance.now();
151
+ }
262
152
  }
263
153
  if (!addedPromptHit) {
264
- const titleMatch = matchTextSearch(sessionTitleSearchText(session), parsed);
265
- if (!titleMatch.matches) continue;
266
- const messages = userMessagesForResult(resultFromSession(session));
154
+ const titleMatch = matchTextSearch(sessionTitleSearchText(session), parsed, regexMatches);
267
155
  const message = messages[messages.length - 1];
268
- if (!message) continue;
269
- const snippet = buildPromptSnippet(message, { mode: "tokens", tokens: [] });
270
- hits.push({ message, rank: titleMatch.score, result: resultFromSession(session, titleMatch.score, [snippet]), snippet });
156
+ if (titleMatch.matches && message) {
157
+ const snippet = buildPromptSnippet(message, { mode: "tokens", tokens: [] });
158
+ hits.push({ message, rank: titleMatch.score, result: resultFromSession(session, titleMatch.score, [snippet]), snippet });
159
+ }
160
+ }
161
+ // Only the best resultLimit hits need to stay alive between batches.
162
+ if (hits.length > limit * 2) {
163
+ hits.sort((a, b) => a.rank - b.rank || compareSessionSearchHitsRecent(a, b));
164
+ hits.length = limit;
271
165
  }
272
166
  }
167
+ signal.throwIfAborted();
273
168
  hits.sort((a, b) => a.rank - b.rank || compareSessionSearchHitsRecent(a, b));
274
169
  return hits.slice(0, limit);
275
170
  }
276
171
 
277
172
  export function formatSessionSearchDate(date: Date): string {
278
- const now = Date.now();
279
- const diffMs = Math.max(0, now - date.getTime());
173
+ const diffMs = Math.max(0, Date.now() - date.getTime());
280
174
  const diffMins = Math.floor(diffMs / 60_000);
281
175
  const diffHours = Math.floor(diffMs / 3_600_000);
282
176
  const diffDays = Math.floor(diffMs / 86_400_000);
@@ -11,8 +11,18 @@ export interface QolSessionSearchSession {
11
11
  name?: string;
12
12
  parentSessionPath?: string;
13
13
  path: string;
14
+ /** Prepared once per index load, not read from disk during a query. */
15
+ canonicalCwd?: string;
16
+ userMessages?: QolSessionUserMessage[];
14
17
  }
15
18
 
19
+ /** The session-search index as the overlay sees it: still loading, loaded,
20
+ * or failed with the load's error. */
21
+ export type QolSessionSearchLoad =
22
+ | { status: "loading" }
23
+ | { status: "ready"; sessions: QolSessionSearchSession[] }
24
+ | { status: "failed"; error: string };
25
+
16
26
  export interface QolSessionSearchResult extends QolSessionSearchSession {
17
27
  rank: number;
18
28
  snippets: string[];
@@ -1,113 +1,18 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { dirname, join, resolve } from "node:path";
4
1
  import { CONFIG_ID } from "./constants.js";
2
+ import { piSettingsPaths, readPackageConfig, readSettingsFiles } from "./package-config.js";
5
3
 
6
4
  export type kendexConfig = Record<string, unknown>;
7
5
 
8
- export function expandHome(input: string): string {
9
- if (input === "~") return homedir();
10
- if (input.startsWith("~/")) return join(homedir(), input.slice(2));
11
- return input;
12
- }
13
-
14
- export function projectSettingsPath(cwd: string): string {
15
- let current = resolve(cwd);
16
- while (true) {
17
- const candidate = join(current, ".pi", "settings.json");
18
- if (existsSync(candidate)) return candidate;
19
- if (existsSync(join(current, ".pi")) || existsSync(join(current, ".git")) || existsSync(join(current, ".kendex-lock.json"))) return candidate;
20
- const parent = dirname(current);
21
- if (parent === current) return join(resolve(cwd), ".pi", "settings.json");
22
- current = parent;
23
- }
24
- }
25
-
26
- const PROJECT_TRUST_SYMBOL = Symbol.for("kendex.pi.project-trust");
27
-
28
- interface ProjectTrustRegistry {
29
- projectSettings?: Map<string, boolean>;
30
- }
31
-
32
- function projectTrustRegistry(): ProjectTrustRegistry {
33
- const host = globalThis as unknown as Record<PropertyKey, ProjectTrustRegistry | undefined>;
34
- const existing = host[PROJECT_TRUST_SYMBOL];
35
- if (existing) return existing;
36
- const created: ProjectTrustRegistry = {};
37
- host[PROJECT_TRUST_SYMBOL] = created;
38
- return created;
39
- }
40
-
41
- export function recordProjectTrust(ctx: { cwd?: string; isProjectTrusted?: () => boolean }): void {
42
- if (!ctx.cwd) return;
43
- let trusted = true;
44
- try {
45
- trusted = ctx.isProjectTrusted?.() === true;
46
- } catch {
47
- trusted = false;
48
- }
49
- const registry = projectTrustRegistry();
50
- if (!registry.projectSettings) registry.projectSettings = new Map();
51
- registry.projectSettings.set(projectSettingsPath(ctx.cwd), trusted);
52
- }
53
-
54
- function projectSettingsTrusted(settingsPath: string): boolean {
55
- return projectTrustRegistry().projectSettings?.get(settingsPath) === true;
56
- }
57
-
58
- /** Root-anchored as `crates/core/src/harness/pi.rs::pi_root_is_absolute_for`
59
- * means it, which `isAbsolute` is not: it calls a driveless `\root` absolute
60
- * where the renderer does not, putting the two on different roots. Hoisted, so
61
- * a circular import cannot reach it inside a temporal dead zone. */
62
- function rootAnchored(path: string, windows: boolean): boolean { return windows ? /^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/][^\\/]+)/.test(path) : path.startsWith("/"); }
63
-
64
- export function piSettingsPaths(cwd = process.cwd()): string[] {
65
- const override = expandHome(process.env.PI_CODING_AGENT_DIR?.trim() || "");
66
- const userDir = resolve(rootAnchored(override, process.platform === "win32") ? override : expandHome("~/.pi/agent"));
67
- const user = join(userDir, "settings.json");
68
- const project = projectSettingsPath(cwd);
69
- return projectSettingsTrusted(project) ? [user, project] : [user];
70
- }
71
-
72
- function isRecord(value: unknown): value is Record<string, unknown> {
73
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
74
- }
75
-
76
- /** Each settings file `piSettingsPaths` names that exists and parses, later
77
- * files overriding earlier ones. A malformed file is skipped, as Pi's own
78
- * settings load treats one as empty. */
79
- function readPiSettingsFiles(cwd?: string): Record<string, any>[] {
80
- const files: Record<string, any>[] = [];
81
- for (const settingsPath of piSettingsPaths(cwd)) {
82
- if (!existsSync(settingsPath)) continue;
83
- try {
84
- const parsed = JSON.parse(readFileSync(settingsPath, "utf8"));
85
- if (isRecord(parsed)) files.push(parsed);
86
- } catch {
87
- // Ignore a malformed settings file.
88
- }
89
- }
90
- return files;
91
- }
92
-
93
- export function readPackageConfig(packageId: string, cwd?: string): Record<string, unknown> {
94
- const merged: Record<string, unknown> = {};
95
- for (const settings of readPiSettingsFiles(cwd)) {
96
- const config = settings.kendex?.extensionManager?.config?.[packageId];
97
- if (isRecord(config)) Object.assign(merged, config);
98
- }
99
- return merged;
100
- }
101
-
102
6
  /** Pi core's `compaction.enabled` over the files QOL reads its own config
103
7
  * from, resolved as Pi's `SettingsManager.getCompactionEnabled` resolves it:
104
8
  * the last file that sets the key decides, a key no file sets is Pi's default,
105
9
  * true, and any falsy value turns compaction off. */
106
10
  export function piCompactionEnabled(cwd?: string): boolean {
107
11
  let enabled: unknown;
108
- for (const settings of readPiSettingsFiles(cwd)) {
109
- const compaction = settings.compaction;
110
- if (isRecord(compaction) && compaction.enabled !== undefined) enabled = compaction.enabled;
12
+ for (const file of readSettingsFiles(piSettingsPaths(cwd))) {
13
+ if (file.kind !== "parsed") continue;
14
+ const compaction = file.settings.compaction as { enabled?: unknown } | null | undefined;
15
+ if (compaction && typeof compaction === "object" && !Array.isArray(compaction) && compaction.enabled !== undefined) enabled = compaction.enabled;
111
16
  }
112
17
  return Boolean(enabled ?? true);
113
18
  }