@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.
package/README.md CHANGED
@@ -29,6 +29,19 @@ Requires Pi 0.86.0 or newer. Restart Pi after installation. Use `kendex update-p
29
29
 
30
30
  The extension reads your enabled features when Pi starts. It adds their editor controls, commands and event handlers. Session actions update the session or queue messages for the agent. Notifications report events through your selected channels. Compaction settings control when long conversations are summarized.
31
31
 
32
+ ## Memory use
33
+
34
+ - A session without a UI loads no session-search index. With a UI, the index is loaded shortly after start and kept for `sessionSearch.cacheTtlSeconds` (default 300 seconds; 0 keeps it until the session ends). A search after the index was released opens at once and fills in when the index has loaded again.
35
+ - The index keeps at most 32,768 characters of message text per session and 8,388,608 characters in total, newest session first. A session past the total is searched by name, path and first prompt only.
36
+ - Prepared user prompts have the same per-session and total character limits, measured in decoded UTF-16 code units. Indexing skips JSONL records above 2,097,152 decoded UTF-16 code units, not bytes. Copy and Fork read the complete selected prompt separately. Search waits 20 ms after the last edit and cancels an older query. Regex matching runs in a disposable worker with a 25 ms deadline and a separate 1 second startup deadline. A deadline error appears in the search popup.
37
+ - Submitted image paths and existing clipboard images share a 20 MiB raw-byte limit. The extension checks all file sizes before reading any image. A refusal blocks submission and reports an error. It restores the rejected prompt only if the editor is empty; a newer draft stays unchanged. Print and JSON callers receive the diagnostic on stderr.
38
+ - Where Pi cannot switch sessions directly, a chosen resume or fork waits as one `/search:resume-pending` command in the editor. Only the latest one is kept, and it is dropped when the session ends.
39
+ - The separate on-demand parsed-prompt cache keeps at most 64 sessions. The prepared search index uses the character limits above, not this session count. Finished thinking times are kept for at most 256 blocks. Thinking labels are released at the end of each agent run, and notification cooldown entries once their cooldown has passed and all of them when the session ends.
40
+
41
+ ## Saved files
42
+
43
+ At session start, the extension deletes budget handoff files whose recorded working directory no longer exists. It also deletes files older than 5 days. Both timestamped snapshots and `latest.json` follow this rule.
44
+
32
45
  ## Settings
33
46
 
34
47
  The settings editor writes project values to `.pi/settings.json`. The default user file is `~/.pi/agent/settings.json`. `PI_CODING_AGENT_DIR` changes the user directory. Package values are stored under `kendex.extensionManager.config["@vanillagreen/pi-qol"]`.
@@ -4,11 +4,12 @@
4
4
  // drops something critical. The pure data-building + write logic lives here
5
5
  // so it can be unit-tested without the pi-coding-agent / pi-ai peer deps.
6
6
 
7
- import { mkdirSync, writeFileSync } from "node:fs";
8
- import { basename, dirname, join, resolve } from "node:path";
7
+ import { writeFileSync } from "node:fs";
8
+ import { basename, dirname, join } from "node:path";
9
9
 
10
+ import { openLaneDir } from "../../scripts/lane-retention.js";
10
11
  import { QOL_BUDGET_HANDOFF_FOLDER, QOL_BUDGET_HANDOFF_LATEST } from "./constants.js";
11
- import { expandHome } from "./settings.js";
12
+ import { piUserDir } from "./package-config.js";
12
13
 
13
14
  export interface QolBudgetHandoff {
14
15
  reason: string;
@@ -22,17 +23,6 @@ export interface QolBudgetHandoff {
22
23
  model?: string;
23
24
  }
24
25
 
25
- /** Root-anchored as `crates/core/src/harness/pi.rs::pi_root_is_absolute_for`
26
- * means it, which `isAbsolute` is not: it calls a driveless `\root` absolute
27
- * where the renderer does not, putting the two on different roots. Hoisted, so
28
- * a circular import cannot reach it inside a temporal dead zone. */
29
- function rootAnchored(path: string, windows: boolean): boolean { return windows ? /^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/][^\\/]+)/.test(path) : path.startsWith("/"); }
30
-
31
- export function piUserDir(): string {
32
- const override = expandHome(process.env.PI_CODING_AGENT_DIR?.trim() || "");
33
- return resolve(rootAnchored(override, process.platform === "win32") ? override : expandHome("~/.pi/agent"));
34
- }
35
-
36
26
  export function safeFileName(value: string): string {
37
27
  return value.replace(/[^\w.-]+/g, "_");
38
28
  }
@@ -157,16 +147,13 @@ export function handoffBaseDir(sessionId: string, root: string = piUserDir()): s
157
147
 
158
148
  export interface WriteHandoffOptions {
159
149
  enabled: boolean;
150
+ cwd: string;
160
151
  root?: string;
161
152
  /** Injection point so tests can stub fs without mocking node:fs. */
162
153
  writer?: (path: string, payload: string) => void;
163
154
  mkdir?: (path: string) => void;
164
155
  }
165
156
 
166
- function defaultMkdir(path: string): void {
167
- mkdirSync(path, { recursive: true, mode: 0o700 });
168
- }
169
-
170
157
  function defaultWriter(path: string, payload: string): void {
171
158
  writeFileSync(path, payload, { mode: 0o600 });
172
159
  }
@@ -176,7 +163,7 @@ export function writeBudgetHandoffArtifact(handoff: QolBudgetHandoff, options: W
176
163
  const baseDir = handoffBaseDir(handoff.sessionId, options.root);
177
164
  const stampedPath = handoffStampedPath(baseDir, handoff.timestamp);
178
165
  const latestPath = join(dirname(baseDir), basename(baseDir), QOL_BUDGET_HANDOFF_LATEST);
179
- const mkdir = options.mkdir ?? defaultMkdir;
166
+ const mkdir = options.mkdir ?? ((path: string) => openLaneDir(path, options.cwd));
180
167
  const writer = options.writer ?? defaultWriter;
181
168
  try {
182
169
  mkdir(baseDir);
@@ -252,7 +252,7 @@ export function buildBudgetHandoff(ctx: ExtensionContext, options: {
252
252
 
253
253
  export function writeBudgetHandoffArtifact(ctx: ExtensionContext, handoff: QolBudgetHandoff): HandoffWriteResult {
254
254
  const enabled = settingBoolean("compaction.handoffArtifactEnabled", true, ctx.cwd);
255
- const result = writeBudgetHandoffArtifactRaw(handoff, { enabled });
255
+ const result = writeBudgetHandoffArtifactRaw(handoff, { cwd: ctx.cwd, enabled });
256
256
  if (result.error) {
257
257
  compactionNotify(ctx, `QOL handoff artifact write failed: ${result.error}`, "warning");
258
258
  }
@@ -74,7 +74,18 @@ export const DEFAULT_SESSION_SEARCH_PREVIEW_SNIPPETS = 6;
74
74
  export const DEFAULT_SESSION_SEARCH_SHORTCUT = "f2";
75
75
  export const DEFAULT_SESSION_SEARCH_SUMMARY_INPUT_CHARS = 180_000;
76
76
  export const DEFAULT_SESSION_SEARCH_SUMMARY_MAX_TOKENS = 4096;
77
- export const DEFAULT_SESSION_SEARCH_CACHE_TTL_SECONDS = 0;
77
+ /** How long a loaded session-search index is kept. Past it the index is
78
+ * released, and the next search loads it again. */
79
+ export const DEFAULT_SESSION_SEARCH_CACHE_TTL_SECONDS = 300;
80
+ /** Characters of one session's message text the search index keeps. */
81
+ export const SESSION_SEARCH_TEXT_MAX_CHARS_PER_SESSION = 32_768;
82
+ /** Characters of message text the whole search index keeps, newest session
83
+ * first; sessions past it are searched by name, path and first prompt only. */
84
+ export const SESSION_SEARCH_TEXT_MAX_CHARS = 8_388_608;
85
+ /** Sessions whose user prompts the search overlay keeps parsed. */
86
+ export const SESSION_SEARCH_USER_MESSAGES_MAX_SESSIONS = 64;
87
+ /** Finished thinking blocks whose time a redrawn message can show again. */
88
+ export const THINKING_TIMER_MAX_DURATIONS = 256;
78
89
  export const SESSION_SEARCH_OVERLAY_HEIGHT_RATIO = 0.9;
79
90
 
80
91
  export const DEFAULT_AUTO_RENAME_MODEL = "openai-codex/gpt-5.4-mini";
@@ -1,4 +1,4 @@
1
- import { readPackageConfig } from "./settings.js";
1
+ import { readPackageConfig } from "./package-config.js";
2
2
 
3
3
  export type GlyphStyle = "unicode" | "ascii";
4
4
  export type GlobalGlyphStyleOverride = "inherit" | GlyphStyle;
@@ -1,9 +1,11 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync } from "node:fs";
2
+ import { open } from "node:fs/promises";
2
3
  import { basename, resolve } from "node:path";
3
4
  import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
4
5
  import { applyVisibleReplacements, buildVisibleMap, type VisibleReplacement } from "./ansi.js";
5
6
  import { IMAGE_EXTENSIONS, IMAGE_PATH_PATTERN } from "./constants.js";
6
- import { expandHome, settingBoolean } from "./settings.js";
7
+ import { expandHome } from "./package-config.js";
8
+ import { settingBoolean } from "./settings.js";
7
9
 
8
10
  function stripAtPrefix(path: string): string {
9
11
  return path.startsWith("@") ? path.slice(1) : path;
@@ -41,11 +43,44 @@ function mimeTypeForPath(path: string): string {
41
43
  return "image/png";
42
44
  }
43
45
 
44
- export function imageContentForPath(path: string): { type: "image"; data: string; mimeType: string } | undefined {
46
+ /** Submitted image paths and clipboard images share this raw-byte budget. */
47
+ export const SUBMITTED_IMAGES_MAX_BYTES = 20 * 1024 * 1024;
48
+
49
+ /** Stat every open file before reading any of them. Read only the observed
50
+ * bytes and one growth probe, so a growing file cannot bypass the budget. */
51
+ export async function imageContentsForPaths(paths: string[], existing: Array<{ data: string }> = []): Promise<Array<{ type: "image"; data: string; mimeType: string }>> {
52
+ const files: Array<{ path: string; handle: Awaited<ReturnType<typeof open>>; size: number }> = [];
53
+ let total = existing.reduce((sum, image) => sum + Buffer.byteLength(image.data, "base64"), 0);
54
+ const checkSize = () => {
55
+ if (total > SUBMITTED_IMAGES_MAX_BYTES) throw new Error(`IMAGE_SIZE_LIMIT: ${total} bytes exceeds ${SUBMITTED_IMAGES_MAX_BYTES}`);
56
+ };
45
57
  try {
46
- return { data: readFileSync(path).toString("base64"), mimeType: mimeTypeForPath(path), type: "image" };
47
- } catch {
48
- return undefined;
58
+ checkSize();
59
+ for (const path of paths) {
60
+ const handle = await open(path, "r");
61
+ const file = { path, handle, size: 0 };
62
+ files.push(file);
63
+ const stat = await handle.stat();
64
+ if (!stat.isFile()) throw new Error(`IMAGE_NOT_FILE: ${path}`);
65
+ file.size = stat.size;
66
+ total += stat.size;
67
+ checkSize();
68
+ }
69
+ const buffers: Buffer[] = [];
70
+ for (const file of files) {
71
+ const buffer = Buffer.alloc(file.size + 1);
72
+ let offset = 0;
73
+ while (offset < buffer.length) {
74
+ const { bytesRead } = await file.handle.read(buffer, offset, buffer.length - offset, offset);
75
+ if (bytesRead === 0) break;
76
+ offset += bytesRead;
77
+ }
78
+ if (offset > file.size) throw new Error(`IMAGE_SIZE_CHANGED: ${file.path}`);
79
+ buffers.push(buffer.subarray(0, offset));
80
+ }
81
+ return files.map((file, index) => ({ type: "image", data: buffers[index]!.toString("base64"), mimeType: mimeTypeForPath(file.path) }));
82
+ } finally {
83
+ await Promise.all(files.map((file) => file.handle.close()));
49
84
  }
50
85
  }
51
86
 
@@ -22,6 +22,9 @@ export interface QolNotificationService {
22
22
  notifyQuestionOpened(ctx: ExtensionContext | undefined, event: QuestionOpenedEventLike): boolean;
23
23
  }
24
24
 
25
+ /** Last send time per notification key, for the cooldown. An entry older than
26
+ * the cooldown suppresses nothing, so each send drops those; question keys
27
+ * carry a request id, so without the drop the map grows per question. */
25
28
  const lastNotificationAt = new Map<string, number>();
26
29
  const lastQuestionNotificationAt = new Map<string, number>();
27
30
  let tmuxMarkedTarget: string | undefined;
@@ -288,6 +291,13 @@ function notificationEnabledFor(kind: QolNotificationKind, cwd?: string): boolea
288
291
  }
289
292
  }
290
293
 
294
+ /** Forget every notification cooldown and question dedup key; they belong to
295
+ * the session that sent them. */
296
+ export function resetQolNotificationCooldowns(): void {
297
+ lastNotificationAt.clear();
298
+ lastQuestionNotificationAt.clear();
299
+ }
300
+
291
301
  /**
292
302
  * The window mark and the tmux message never touch the terminal, so they start
293
303
  * beside the terminal writes rather than behind them.
@@ -315,7 +325,10 @@ export function sendQolNotification(pi: QolNotificationExec, ctx: ExtensionConte
315
325
  const now = Date.now();
316
326
  const last = lastNotificationAt.get(key) ?? 0;
317
327
  if (cooldownMs > 0 && now - last < cooldownMs) return Promise.resolve();
318
- lastNotificationAt.set(key, now);
328
+ for (const [storedKey, sentAt] of lastNotificationAt) {
329
+ if (now - sentAt >= cooldownMs) lastNotificationAt.delete(storedKey);
330
+ }
331
+ if (cooldownMs > 0) lastNotificationAt.set(key, now);
319
332
 
320
333
  const title = sanitizeNotificationPart(settingString("notification.title", DEFAULT_NOTIFICATION_TITLE, cwd), 80) || DEFAULT_NOTIFICATION_TITLE;
321
334
  const text = sanitizeNotificationPart(body, Math.max(40, Math.floor(settingNumber("notification.bodyMaxChars", DEFAULT_NOTIFICATION_BODY_MAX_CHARS, cwd))));
@@ -0,0 +1,445 @@
1
+ /**
2
+ * The settings reader every pi-extensions package vendors. Each package carries
3
+ * this file byte-identical, and `pi-extensions/package-policy.test.mjs` holds
4
+ * the copies equal: change one, then copy it over every other
5
+ * `package-config.ts` in the tree.
6
+ *
7
+ * It owns the Pi user directory, the project-trust registry and the
8
+ * `kendex.extensionManager.config` walk, and it provides the default
9
+ * settings-file order, `piSettingsPaths`. A package that finds its own project
10
+ * root, such as pi-hooks, passes its own path list to `readPackageConfigAt`.
11
+ * Widgets and renderers read settings many times per frame; going to the disk
12
+ * on each read kept every lane's render loop busy. A memoized answer is served
13
+ * for `SETTINGS_RECHECK_MS` with no filesystem, environment or path work. The
14
+ * memos are module state, so each package keeps its own; only the trust
15
+ * registry is shared by every package in the process.
16
+ * `installSettingsCacheRefresh` drops every answer when the extension manager
17
+ * announces a settings change and when a session starts.
18
+ */
19
+ import { existsSync, readFileSync } from "node:fs";
20
+ import { homedir } from "node:os";
21
+ import { dirname, join, resolve } from "node:path";
22
+
23
+ /** What pi-extension-manager emits on `pi.events` after it writes a setting. */
24
+ export const SETTINGS_CHANGED_EVENT = "kendex:extension-settings-changed";
25
+
26
+ /** How long a memoized answer is served before its inputs are read again. */
27
+ export const SETTINGS_RECHECK_MS = 1000;
28
+
29
+ export function expandHome(input: string): string {
30
+ if (input === "~") return homedir();
31
+ if (input.startsWith("~/")) return join(homedir(), input.slice(2));
32
+ return input;
33
+ }
34
+
35
+ /** Root-anchored as `crates/core/src/harness/pi.rs::pi_root_is_absolute_for`
36
+ * means it, which `isAbsolute` is not: it calls a driveless `\root` absolute
37
+ * where the renderer does not, putting the two on different roots. */
38
+ export function rootAnchored(path: string, windows: boolean): boolean {
39
+ return windows ? /^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/][^\\/]+)/.test(path) : path.startsWith("/");
40
+ }
41
+
42
+ /** The user directory and the environment it was resolved under. `HOME` and
43
+ * `USERPROFILE` are what `homedir()` reads, on POSIX and on Windows. */
44
+ interface UserDir {
45
+ agentDir: string | undefined;
46
+ home: string | undefined;
47
+ profile: string | undefined;
48
+ dir: string;
49
+ settings: string;
50
+ }
51
+
52
+ let userDirMemo: UserDir | undefined;
53
+
54
+ function userDir(): UserDir {
55
+ const agentDir = process.env.PI_CODING_AGENT_DIR;
56
+ const home = process.env.HOME;
57
+ const profile = process.env.USERPROFILE;
58
+ const known = userDirMemo;
59
+ if (known !== undefined && known.agentDir === agentDir && known.home === home && known.profile === profile) return known;
60
+ const override = expandHome(agentDir?.trim() || "");
61
+ const dir = resolve(rootAnchored(override, process.platform === "win32") ? override : expandHome("~/.pi/agent"));
62
+ userDirMemo = { agentDir, home, profile, dir, settings: join(dir, "settings.json") };
63
+ return userDirMemo;
64
+ }
65
+
66
+ /** A root-anchored `PI_CODING_AGENT_DIR`, else `~/.pi/agent`, for the
67
+ * environment of this call. The settings reads below memoize it instead,
68
+ * since reading the environment costs more than a warm read. */
69
+ export function piUserDir(): string {
70
+ return userDir().dir;
71
+ }
72
+
73
+ interface MemoEntry {
74
+ /** `performance.now()`, a monotonic clock, so a backward wall-clock step
75
+ * cannot hold an entry past its window. */
76
+ readAt: number;
77
+ fingerprint: string | undefined;
78
+ value: unknown;
79
+ }
80
+
81
+ const memo = new Map<string, MemoEntry>();
82
+
83
+ /** Drops every memoized answer so the next read goes back to disk and to the
84
+ * environment. */
85
+ export function clearPackageConfigCache(): void {
86
+ memo.clear();
87
+ cwdSettings.clear();
88
+ userSettingsMemo = undefined;
89
+ }
90
+
91
+ /** The entry for `key` while its window is open. */
92
+ function openEntry(store: Map<string, MemoEntry>, key: string, now: number): MemoEntry | undefined {
93
+ const entry = store.get(key);
94
+ return entry !== undefined && now - entry.readAt < SETTINGS_RECHECK_MS ? entry : undefined;
95
+ }
96
+
97
+ /**
98
+ * `compute()` for `key`, served from memory for `SETTINGS_RECHECK_MS`. Once the
99
+ * window closes, `fingerprint` (when given) runs first, and a fingerprint equal
100
+ * to the one the value was computed under keeps the value, and its identity,
101
+ * for another window without calling `compute`. Without a fingerprint the
102
+ * value is computed again, and so is a value `reusable` answered `false` for,
103
+ * whatever the fingerprint says. `clearPackageConfigCache` drops every entry.
104
+ */
105
+ export function settingsMemo<T>(key: string, compute: () => T, fingerprint?: () => string, reusable?: (value: T) => boolean): T {
106
+ return memoIn(memo, key, performance.now(), compute, fingerprint, reusable);
107
+ }
108
+
109
+ /** `settingsMemo` over `store` at `now`. One public read takes the clock
110
+ * once and hands it down, since reading it costs as much as a warm read. */
111
+ function memoIn<T>(store: Map<string, MemoEntry>, key: string, now: number, compute: () => T, fingerprint?: () => string, reusable?: (value: T) => boolean): T {
112
+ const open = openEntry(store, key, now);
113
+ if (open !== undefined) return open.value as T;
114
+ const entry = store.get(key);
115
+ const print = fingerprint?.();
116
+ if (entry !== undefined && print !== undefined && entry.fingerprint === print) {
117
+ entry.readAt = now;
118
+ return entry.value as T;
119
+ }
120
+ const value = compute();
121
+ store.set(key, { readAt: now, fingerprint: reusable === undefined || reusable(value) ? print : undefined, value });
122
+ return value;
123
+ }
124
+
125
+ /** The project settings file for `cwd`: the nearest ancestor's
126
+ * `.pi/settings.json`, stopping at a `.pi`, `.git` or `.kendex-lock.json`. */
127
+ export function projectSettingsPath(cwd: string): string {
128
+ let current = resolve(cwd);
129
+ while (true) {
130
+ const candidate = join(current, ".pi", "settings.json");
131
+ if (existsSync(candidate)) return candidate;
132
+ if (existsSync(join(current, ".pi")) || existsSync(join(current, ".git")) || existsSync(join(current, ".kendex-lock.json"))) return candidate;
133
+ const parent = dirname(current);
134
+ if (parent === current) return join(resolve(cwd), ".pi", "settings.json");
135
+ current = parent;
136
+ }
137
+ }
138
+
139
+ const PROJECT_TRUST_SYMBOL = Symbol.for("kendex.pi.project-trust");
140
+
141
+ interface ProjectTrustRegistry {
142
+ projectSettings?: Map<string, boolean>;
143
+ }
144
+
145
+ /** The registry once found or made; no package replaces it after that. */
146
+ let trustRegistry: ProjectTrustRegistry | undefined;
147
+
148
+ /** Shared by every package in the process: whichever package records Pi's
149
+ * trust answer first, every package reads it. */
150
+ function projectTrustRegistry(): ProjectTrustRegistry {
151
+ if (trustRegistry !== undefined) return trustRegistry;
152
+ const host = globalThis as unknown as Record<PropertyKey, ProjectTrustRegistry | undefined>;
153
+ trustRegistry = host[PROJECT_TRUST_SYMBOL] ?? {};
154
+ host[PROJECT_TRUST_SYMBOL] = trustRegistry;
155
+ return trustRegistry;
156
+ }
157
+
158
+ /**
159
+ * Pi's answer to "has this person trusted this workspace". Only a plain `true`
160
+ * counts: a Pi with no such method, or one that throws, is not trusted. The
161
+ * answer gates reading a project's settings and running a project's own
162
+ * scripts, both safe to withhold and unsafe to grant by accident.
163
+ */
164
+ export function projectTrusted(ctx: { isProjectTrusted?: () => boolean }): boolean {
165
+ try {
166
+ return ctx.isProjectTrusted?.() === true;
167
+ } catch {
168
+ return false;
169
+ }
170
+ }
171
+
172
+ /** Records Pi's trust answer against the settings file `settingsPath`. */
173
+ export function recordSettingsTrust(settingsPath: string, ctx: { isProjectTrusted?: () => boolean }): void {
174
+ const registry = projectTrustRegistry();
175
+ if (!registry.projectSettings) registry.projectSettings = new Map();
176
+ registry.projectSettings.set(settingsPath, projectTrusted(ctx));
177
+ }
178
+
179
+ export function recordProjectTrust(ctx: { cwd?: string; isProjectTrusted?: () => boolean }): void {
180
+ if (!ctx.cwd) return;
181
+ recordSettingsTrust(projectSettingsPath(ctx.cwd), ctx);
182
+ }
183
+
184
+ /** Read on every call, never memoized: a trust answer recorded by any package
185
+ * takes effect on the next read. */
186
+ export function settingsFileTrusted(settingsPath: string): boolean {
187
+ return projectTrustRegistry().projectSettings?.get(settingsPath) === true;
188
+ }
189
+
190
+ /** The project settings file for `cwd` when Pi trusts the project, else
191
+ * `undefined`. */
192
+ export function trustedProjectSettingsPath(cwd = process.cwd()): string | undefined {
193
+ const view = cwdSettingsAt(cwd, performance.now());
194
+ return view.trusted ? view.project : undefined;
195
+ }
196
+
197
+ export function projectSettingsTrustedForCwd(cwd = process.cwd()): boolean {
198
+ return trustedProjectSettingsPath(cwd) !== undefined;
199
+ }
200
+
201
+ /** The user settings file, resolved from the environment at most once per
202
+ * window. */
203
+ let userSettingsMemo: { readAt: number; path: string } | undefined;
204
+
205
+ function userSettingsPathAt(now: number): string {
206
+ const known = userSettingsMemo;
207
+ if (known !== undefined && now - known.readAt < SETTINGS_RECHECK_MS) return known.path;
208
+ const path = userDir().settings;
209
+ userSettingsMemo = { readAt: now, path };
210
+ return path;
211
+ }
212
+
213
+ /** The user settings file the lists below were built on; a new user directory
214
+ * starts a new set of lists. */
215
+ let pathListsUser: string | undefined;
216
+ const pathLists = new Map<string | undefined, readonly string[]>();
217
+
218
+ /**
219
+ * The user settings file, then `project` when given. One frozen list per
220
+ * answer, so `readSettingsFiles` finds its memo by the list's identity. The
221
+ * caller decides whether `project` is trusted.
222
+ */
223
+ export function userAndProjectSettingsPaths(project: string | undefined): readonly string[] {
224
+ return userAndProjectSettingsPathsAt(project, performance.now());
225
+ }
226
+
227
+ function userAndProjectSettingsPathsAt(project: string | undefined, now: number): readonly string[] {
228
+ const user = userSettingsPathAt(now);
229
+ if (user !== pathListsUser) {
230
+ pathLists.clear();
231
+ pathListsUser = user;
232
+ }
233
+ let paths = pathLists.get(project);
234
+ if (paths === undefined) {
235
+ paths = Object.freeze(project === undefined ? [user] : [user, project]);
236
+ pathLists.set(project, paths);
237
+ }
238
+ return paths;
239
+ }
240
+
241
+ /** The user settings file, then the project's when Pi trusts the project. */
242
+ export function piSettingsPaths(cwd = process.cwd()): readonly string[] {
243
+ return cwdSettingsAt(cwd, performance.now()).paths;
244
+ }
245
+
246
+ export type SettingsRecord = Record<string, unknown>;
247
+
248
+ /** One settings file that exists. A file that does not parse, or that cannot be
249
+ * read for any reason but its absence, is `malformed`; a document that parses
250
+ * to something other than an object reads as an empty one. */
251
+ export type SettingsFile =
252
+ | { kind: "parsed"; path: string; settings: Readonly<SettingsRecord> }
253
+ | { kind: "malformed"; path: string; error: string };
254
+
255
+ function isRecord(value: unknown): value is SettingsRecord {
256
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
257
+ }
258
+
259
+ /** Memoized values are shared by every caller, so a caller that wrote into one
260
+ * would change what the next caller reads. Frozen, that write throws instead. */
261
+ function deepFreeze<T>(value: T): T {
262
+ if (value && typeof value === "object" && !Object.isFrozen(value)) {
263
+ Object.freeze(value);
264
+ for (const child of Object.values(value)) deepFreeze(child);
265
+ }
266
+ return value;
267
+ }
268
+
269
+ type FileText = { kind: "absent" } | { kind: "text"; text: string } | { kind: "unreadable"; error: string };
270
+
271
+ function readText(path: string): FileText {
272
+ try {
273
+ return { kind: "text", text: readFileSync(path, "utf8") };
274
+ } catch (error) {
275
+ const code = (error as { code?: unknown } | null)?.code;
276
+ if (code === "ENOENT" || code === "ENOTDIR") return { kind: "absent" };
277
+ return { kind: "unreadable", error: error instanceof Error ? error.message : String(error) };
278
+ }
279
+ }
280
+
281
+ /** Each path list's memo key, so a list a caller keeps is joined once. */
282
+ const settingsFilesKeys = new WeakMap<readonly string[], string>();
283
+
284
+ function settingsFilesKey(paths: readonly string[]): string {
285
+ let key = settingsFilesKeys.get(paths);
286
+ if (key === undefined) {
287
+ key = `settings-files\0${paths.join("\0")}`;
288
+ settingsFilesKeys.set(paths, key);
289
+ }
290
+ return key;
291
+ }
292
+
293
+ /**
294
+ * The files at `paths` that exist, in order. Each window re-reads their text;
295
+ * text equal to what the last parse saw keeps the last answer, so an unchanged
296
+ * file is parsed once and its callers keep one identity for it.
297
+ */
298
+ export function readSettingsFiles(paths: readonly string[]): readonly SettingsFile[] {
299
+ return readSettingsFilesAt(paths, performance.now());
300
+ }
301
+
302
+ function readSettingsFilesAt(paths: readonly string[], now: number): readonly SettingsFile[] {
303
+ const key = settingsFilesKey(paths);
304
+ // Checked before the closures below exist, so a warm read allocates nothing.
305
+ const open = openEntry(memo, key, now);
306
+ if (open !== undefined) return open.value as readonly SettingsFile[];
307
+ let texts: FileText[] = [];
308
+ return memoIn(
309
+ memo,
310
+ key,
311
+ now,
312
+ () =>
313
+ deepFreeze(
314
+ paths.flatMap((path, index): SettingsFile[] => {
315
+ const text = texts[index] ?? readText(path);
316
+ if (text.kind === "absent") return [];
317
+ if (text.kind === "unreadable") return [{ kind: "malformed", path, error: text.error }];
318
+ try {
319
+ const parsed: unknown = JSON.parse(text.text);
320
+ return [{ kind: "parsed", path, settings: isRecord(parsed) ? parsed : {} }];
321
+ } catch (error) {
322
+ return [{ kind: "malformed", path, error: error instanceof Error ? error.message : String(error) }];
323
+ }
324
+ }),
325
+ ),
326
+ () => {
327
+ texts = paths.map(readText);
328
+ return JSON.stringify(texts);
329
+ },
330
+ );
331
+ }
332
+
333
+ /** `packageId`'s object under `kendex.extensionManager.config` in one parsed
334
+ * settings document, or `undefined` where it holds none. */
335
+ export function packageConfigIn(settings: Readonly<SettingsRecord>, packageId: string): Readonly<SettingsRecord> | undefined {
336
+ const kendex = settings.kendex;
337
+ const manager = isRecord(kendex) ? kendex.extensionManager : undefined;
338
+ const config = isRecord(manager) ? manager.config : undefined;
339
+ const own = isRecord(config) ? config[packageId] : undefined;
340
+ return isRecord(own) ? own : undefined;
341
+ }
342
+
343
+ /** What reads for one cwd resolve to: its project settings file, the trust
344
+ * answer its paths were built under, the files at those paths and the configs
345
+ * merged over them. */
346
+ interface CwdSettings {
347
+ readAt: number;
348
+ project: string;
349
+ trusted: boolean;
350
+ paths: readonly string[];
351
+ files: readonly SettingsFile[];
352
+ configs: Map<string, Readonly<SettingsRecord>>;
353
+ }
354
+
355
+ const cwdSettings = new Map<string, CwdSettings>();
356
+
357
+ /**
358
+ * The settings for `cwd`, served for `SETTINGS_RECHECK_MS` with one map
359
+ * lookup and the trust lookup: no environment, path or file work. Trust is
360
+ * read on every call, so an answer any package records applies at once. Once
361
+ * the window closes, the project walk runs again, and the user settings file
362
+ * and the files come from their own memos.
363
+ */
364
+ function cwdSettingsAt(cwd: string, now: number): CwdSettings {
365
+ const known = cwdSettings.get(cwd);
366
+ const open = known !== undefined && now - known.readAt < SETTINGS_RECHECK_MS ? known : undefined;
367
+ if (open !== undefined && settingsFileTrusted(open.project) === open.trusted) return open;
368
+ const project = open !== undefined ? open.project : projectSettingsPath(cwd);
369
+ const trusted = settingsFileTrusted(project);
370
+ const paths = userAndProjectSettingsPathsAt(trusted ? project : undefined, now);
371
+ const files = readSettingsFilesAt(paths, now);
372
+ // The files may come from a window another cwd opened; this view closes
373
+ // with it, so an edit is never served for longer than one window.
374
+ const filesReadAt = memo.get(settingsFilesKey(paths))?.readAt ?? now;
375
+ const readAt = Math.min(open !== undefined ? open.readAt : now, filesReadAt);
376
+ const view: CwdSettings = { readAt, project, trusted, paths, files, configs: configsOver(files) };
377
+ cwdSettings.set(cwd, view);
378
+ return view;
379
+ }
380
+
381
+ /** Merged configs per package id, keyed by the file list they were merged
382
+ * from: `readSettingsFiles` hands back a new list whenever a file changed. */
383
+ const configsByFiles = new WeakMap<readonly SettingsFile[], Map<string, Readonly<SettingsRecord>>>();
384
+
385
+ /** `packageId`'s config over the files at `paths`, later files overriding
386
+ * earlier ones key by key. A malformed file contributes nothing. */
387
+ export function readPackageConfigAt(packageId: string, paths: readonly string[]): Readonly<SettingsRecord> {
388
+ const files = readSettingsFiles(paths);
389
+ return packageConfigOver(packageId, files, configsOver(files));
390
+ }
391
+
392
+ function configsOver(files: readonly SettingsFile[]): Map<string, Readonly<SettingsRecord>> {
393
+ let configs = configsByFiles.get(files);
394
+ if (!configs) {
395
+ configs = new Map();
396
+ configsByFiles.set(files, configs);
397
+ }
398
+ return configs;
399
+ }
400
+
401
+ function packageConfigOver(packageId: string, files: readonly SettingsFile[], configs: Map<string, Readonly<SettingsRecord>>): Readonly<SettingsRecord> {
402
+ const known = configs.get(packageId);
403
+ if (known) return known;
404
+ const merged: SettingsRecord = {};
405
+ for (const file of files) {
406
+ if (file.kind !== "parsed") continue;
407
+ const config = packageConfigIn(file.settings, packageId);
408
+ if (config) Object.assign(merged, config);
409
+ }
410
+ const frozen = Object.freeze(merged);
411
+ configs.set(packageId, frozen);
412
+ return frozen;
413
+ }
414
+
415
+ /** `packageId`'s config over `piSettingsPaths(cwd)`. */
416
+ export function readPackageConfig(packageId: string, cwd = process.cwd()): Readonly<SettingsRecord> {
417
+ const view = cwdSettingsAt(cwd, performance.now());
418
+ return packageConfigOver(packageId, view.files, view.configs);
419
+ }
420
+
421
+ /** The part of Pi's `ExtensionAPI` the refresh needs. */
422
+ export interface SettingsRefreshHost {
423
+ on(event: "session_start", handler: () => void): unknown;
424
+ on(event: "session_shutdown", handler: () => void): unknown;
425
+ events: { on(channel: string, handler: (data: unknown) => void): () => void };
426
+ }
427
+
428
+ /**
429
+ * Drops every memoized answer when a session starts, which is where the cwd
430
+ * and the project's trust can change, and whenever pi-extension-manager
431
+ * announces a settings change. The subscription lives from `session_start` to
432
+ * `session_shutdown`, as Pi asks of any listener.
433
+ */
434
+ export function installSettingsCacheRefresh(pi: SettingsRefreshHost): void {
435
+ let unsubscribe: (() => void) | undefined;
436
+ pi.on("session_start", () => {
437
+ clearPackageConfigCache();
438
+ unsubscribe ??= pi.events.on(SETTINGS_CHANGED_EVENT, () => clearPackageConfigCache());
439
+ });
440
+ pi.on("session_shutdown", () => {
441
+ unsubscribe?.();
442
+ unsubscribe = undefined;
443
+ clearPackageConfigCache();
444
+ });
445
+ }