@vanillagreen/pi-qol 2.2.1 → 2.3.2
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 +11 -0
- package/extensions/qol/compaction-handoff.ts +6 -19
- package/extensions/qol/compaction.ts +1 -1
- package/extensions/qol/constants.ts +12 -1
- package/extensions/qol/glyphs.ts +1 -1
- package/extensions/qol/images.ts +2 -1
- package/extensions/qol/notifications.ts +14 -1
- package/extensions/qol/package-config.ts +445 -0
- package/extensions/qol/session-search/cache.ts +68 -17
- package/extensions/qol/session-search/component.ts +19 -5
- package/extensions/qol/session-search/index.ts +17 -9
- package/extensions/qol/session-search/types.ts +7 -0
- package/extensions/qol/settings.ts +5 -100
- package/extensions/qol.ts +35 -6
- package/package.json +4 -3
- package/scripts/lane-retention.ts +187 -0
package/README.md
CHANGED
|
@@ -29,6 +29,17 @@ 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
|
+
- 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.
|
|
37
|
+
- Parsed prompts are kept for at most 64 sessions, and finished thinking times 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.
|
|
38
|
+
|
|
39
|
+
## Saved files
|
|
40
|
+
|
|
41
|
+
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.
|
|
42
|
+
|
|
32
43
|
## Settings
|
|
33
44
|
|
|
34
45
|
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 {
|
|
8
|
-
import { basename, dirname, join
|
|
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 {
|
|
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 ??
|
|
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
|
-
|
|
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";
|
package/extensions/qol/glyphs.ts
CHANGED
package/extensions/qol/images.ts
CHANGED
|
@@ -3,7 +3,8 @@ import { basename, resolve } from "node:path";
|
|
|
3
3
|
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { applyVisibleReplacements, buildVisibleMap, type VisibleReplacement } from "./ansi.js";
|
|
5
5
|
import { IMAGE_EXTENSIONS, IMAGE_PATH_PATTERN } from "./constants.js";
|
|
6
|
-
import { expandHome
|
|
6
|
+
import { expandHome } from "./package-config.js";
|
|
7
|
+
import { settingBoolean } from "./settings.js";
|
|
7
8
|
|
|
8
9
|
function stripAtPrefix(path: string): string {
|
|
9
10
|
return path.startsWith("@") ? path.slice(1) : path;
|
|
@@ -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
|
-
|
|
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
|
+
}
|