@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
4
|
import { SessionManager, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
@@ -8,8 +8,12 @@ import {
|
|
|
8
8
|
DEFAULT_SESSION_SEARCH_SHORTCUT,
|
|
9
9
|
SESSION_SEARCH_PENDING_SYMBOL,
|
|
10
10
|
SESSION_SEARCH_STATUS_KEY,
|
|
11
|
+
SESSION_SEARCH_TEXT_MAX_CHARS,
|
|
12
|
+
SESSION_SEARCH_TEXT_MAX_CHARS_PER_SESSION,
|
|
13
|
+
SESSION_SEARCH_USER_MESSAGES_MAX_SESSIONS,
|
|
11
14
|
} from "../constants.js";
|
|
12
|
-
import { expandHome, piSettingsPaths,
|
|
15
|
+
import { expandHome, piSettingsPaths, readSettingsFiles } from "../package-config.js";
|
|
16
|
+
import { settingBoolean, settingNumber, settingString, settingStringAllowEmpty } from "../settings.js";
|
|
13
17
|
import { forEachSessionJsonlLine } from "./jsonl.js";
|
|
14
18
|
import type {
|
|
15
19
|
QolSessionPaletteAction,
|
|
@@ -23,12 +27,33 @@ import type {
|
|
|
23
27
|
let qolSessionSearchCache: QolSessionSearchSession[] = [];
|
|
24
28
|
let qolSessionSearchLoadedAt = 0;
|
|
25
29
|
let qolSessionSearchLoading: Promise<QolSessionSearchSession[]> | undefined;
|
|
30
|
+
let qolSessionSearchReleaseTimer: ReturnType<typeof setTimeout> | undefined;
|
|
31
|
+
/** Parsed user prompts per session path, oldest insertion first; at most
|
|
32
|
+
* SESSION_SEARCH_USER_MESSAGES_MAX_SESSIONS entries. */
|
|
26
33
|
const qolSessionUserMessagesCache = new Map<string, QolSessionUserMessage[]>();
|
|
27
|
-
|
|
34
|
+
/** The resume or fork action the editor's `/search:resume-pending <id>` line
|
|
35
|
+
* names. Queuing another replaces it, since the editor holds one line. */
|
|
36
|
+
let qolSessionSearchPendingAction: { id: string; action: QolSessionPaletteAction } | undefined;
|
|
28
37
|
let qolSessionSearchPendingActionCounter = 0;
|
|
29
38
|
|
|
30
|
-
|
|
31
|
-
|
|
39
|
+
/** Hold `action` as the one pending action and return the id that claims it. */
|
|
40
|
+
export function queueSessionSearchPendingAction(action: QolSessionPaletteAction): string {
|
|
41
|
+
const id = `ss-${Date.now().toString(36)}-${(++qolSessionSearchPendingActionCounter).toString(36)}`;
|
|
42
|
+
qolSessionSearchPendingAction = { id, action };
|
|
43
|
+
return id;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Remove and return the pending action when `id` names it. */
|
|
47
|
+
export function takeSessionSearchPendingAction(id: string): QolSessionPaletteAction | undefined {
|
|
48
|
+
if (qolSessionSearchPendingAction?.id !== id) return undefined;
|
|
49
|
+
const { action } = qolSessionSearchPendingAction;
|
|
50
|
+
qolSessionSearchPendingAction = undefined;
|
|
51
|
+
return action;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Drop the pending action. Runs on session shutdown. */
|
|
55
|
+
export function clearSessionSearchPendingAction(): void {
|
|
56
|
+
qolSessionSearchPendingAction = undefined;
|
|
32
57
|
}
|
|
33
58
|
|
|
34
59
|
export function getPendingSessionSearchMessage(): QolSessionSearchPendingMessage | undefined {
|
|
@@ -82,16 +107,10 @@ function configuredSessionDir(cwd: string): string | undefined {
|
|
|
82
107
|
const envDir = process.env.PI_CODING_AGENT_SESSION_DIR?.trim();
|
|
83
108
|
if (envDir) return resolveSettingsRelativePath(envDir, join(resolve(cwd), ".pi", "settings.json"));
|
|
84
109
|
let configured: string | undefined;
|
|
85
|
-
for (const
|
|
86
|
-
if (
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (typeof parsed?.sessionDir === "string" && parsed.sessionDir.trim()) {
|
|
90
|
-
configured = resolveSettingsRelativePath(parsed.sessionDir, settingsPath);
|
|
91
|
-
}
|
|
92
|
-
} catch {
|
|
93
|
-
// Ignore malformed optional settings.
|
|
94
|
-
}
|
|
110
|
+
for (const file of readSettingsFiles(piSettingsPaths(cwd))) {
|
|
111
|
+
if (file.kind !== "parsed") continue;
|
|
112
|
+
const sessionDir = file.settings.sessionDir;
|
|
113
|
+
if (typeof sessionDir === "string" && sessionDir.trim()) configured = resolveSettingsRelativePath(sessionDir, file.path);
|
|
95
114
|
}
|
|
96
115
|
return configured;
|
|
97
116
|
}
|
|
@@ -113,16 +132,40 @@ export function defaultSessionSearchScope(cwd?: string): QolSessionSearchScope {
|
|
|
113
132
|
return settingString("sessionSearch.defaultScope", "current", cwd).toLowerCase() === "all" ? "all" : "current";
|
|
114
133
|
}
|
|
115
134
|
|
|
135
|
+
/** Cap the message text the index keeps: each session's text at the per-session
|
|
136
|
+
* limit, and the sum at the index limit, filled newest session first. */
|
|
137
|
+
export function capSessionSearchText(sessions: QolSessionSearchSession[]): QolSessionSearchSession[] {
|
|
138
|
+
let budget = SESSION_SEARCH_TEXT_MAX_CHARS;
|
|
139
|
+
const newestFirst = [...sessions].sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
|
140
|
+
for (const session of newestFirst) {
|
|
141
|
+
const kept = session.allMessagesText.slice(0, Math.min(SESSION_SEARCH_TEXT_MAX_CHARS_PER_SESSION, budget));
|
|
142
|
+
budget -= kept.length;
|
|
143
|
+
session.allMessagesText = kept;
|
|
144
|
+
}
|
|
145
|
+
return sessions;
|
|
146
|
+
}
|
|
147
|
+
|
|
116
148
|
async function loadQolSessionSearchSessions(ctx: ExtensionContext, onProgress?: (loaded: number, total: number) => void): Promise<QolSessionSearchSession[]> {
|
|
117
149
|
const customSessionDir = configuredSessionDir(ctx.cwd);
|
|
118
150
|
const infos = customSessionDir
|
|
119
151
|
? await SessionManager.list(ctx.cwd, customSessionDir, onProgress)
|
|
120
152
|
: await SessionManager.listAll(onProgress);
|
|
121
|
-
return infos.map(sessionInfoToSearchSession).filter((session): session is QolSessionSearchSession => session !== undefined);
|
|
153
|
+
return capSessionSearchText(infos.map(sessionInfoToSearchSession).filter((session): session is QolSessionSearchSession => session !== undefined));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Drop the loaded index and the parsed prompts. Runs when the index outlives
|
|
157
|
+
* its TTL and on session shutdown; the next search loads the index again. */
|
|
158
|
+
export function releaseQolSessionSearchCache(): void {
|
|
159
|
+
if (qolSessionSearchReleaseTimer) clearTimeout(qolSessionSearchReleaseTimer);
|
|
160
|
+
qolSessionSearchReleaseTimer = undefined;
|
|
161
|
+
qolSessionSearchCache = [];
|
|
162
|
+
qolSessionSearchLoadedAt = 0;
|
|
163
|
+
qolSessionUserMessagesCache.clear();
|
|
122
164
|
}
|
|
123
165
|
|
|
124
166
|
export async function refreshQolSessionSearchCache(ctx: ExtensionContext, options?: { force?: boolean; quiet?: boolean }): Promise<QolSessionSearchSession[]> {
|
|
125
167
|
const ttlMs = Math.max(0, settingNumber("sessionSearch.cacheTtlSeconds", DEFAULT_SESSION_SEARCH_CACHE_TTL_SECONDS, ctx.cwd) * 1000);
|
|
168
|
+
// A TTL of 0 keeps the index until session shutdown.
|
|
126
169
|
const fresh = qolSessionSearchCache.length > 0 && (ttlMs === 0 || Date.now() - qolSessionSearchLoadedAt < ttlMs);
|
|
127
170
|
if (!options?.force && fresh) return qolSessionSearchCache;
|
|
128
171
|
if (qolSessionSearchLoading) return qolSessionSearchLoading;
|
|
@@ -131,9 +174,13 @@ export async function refreshQolSessionSearchCache(ctx: ExtensionContext, option
|
|
|
131
174
|
qolSessionSearchLoading = loadQolSessionSearchSessions(ctx, (loaded, total) => {
|
|
132
175
|
if (!options?.quiet && ctx.hasUI) ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, `Loading sessions ${loaded}/${total}`);
|
|
133
176
|
}).then((sessions) => {
|
|
177
|
+
releaseQolSessionSearchCache();
|
|
134
178
|
qolSessionSearchCache = sessions;
|
|
135
179
|
qolSessionSearchLoadedAt = Date.now();
|
|
136
|
-
|
|
180
|
+
if (ttlMs > 0) {
|
|
181
|
+
qolSessionSearchReleaseTimer = setTimeout(releaseQolSessionSearchCache, ttlMs);
|
|
182
|
+
qolSessionSearchReleaseTimer.unref?.();
|
|
183
|
+
}
|
|
137
184
|
return sessions;
|
|
138
185
|
}).finally(() => {
|
|
139
186
|
qolSessionSearchLoading = undefined;
|
|
@@ -187,6 +234,10 @@ export function sessionUserMessages(sessionPath: string): QolSessionUserMessage[
|
|
|
187
234
|
// Ignore unreadable sessions; callers fall back to SessionInfo.firstMessage.
|
|
188
235
|
}
|
|
189
236
|
qolSessionUserMessagesCache.set(sessionPath, messages);
|
|
237
|
+
for (const oldest of qolSessionUserMessagesCache.keys()) {
|
|
238
|
+
if (qolSessionUserMessagesCache.size <= SESSION_SEARCH_USER_MESSAGES_MAX_SESSIONS) break;
|
|
239
|
+
qolSessionUserMessagesCache.delete(oldest);
|
|
240
|
+
}
|
|
190
241
|
return messages;
|
|
191
242
|
}
|
|
192
243
|
|
|
@@ -29,6 +29,7 @@ import type {
|
|
|
29
29
|
QolSessionMessagesState,
|
|
30
30
|
QolSessionPaletteAction,
|
|
31
31
|
QolSessionSearchHit,
|
|
32
|
+
QolSessionSearchLoad,
|
|
32
33
|
QolSessionSearchResult,
|
|
33
34
|
QolSessionSearchScope,
|
|
34
35
|
QolSessionSearchSession,
|
|
@@ -108,7 +109,8 @@ export class QolSessionSearchComponent {
|
|
|
108
109
|
private readonly done: (action: QolSessionPaletteAction) => void;
|
|
109
110
|
private readonly tui: { requestRender(): void; terminal?: { rows?: number } };
|
|
110
111
|
private readonly theme: Theme;
|
|
111
|
-
|
|
112
|
+
/** The index, while it loads, once loaded, or the load's failure. */
|
|
113
|
+
private sessions: QolSessionSearchLoad;
|
|
112
114
|
private readonly cwd: string;
|
|
113
115
|
private readonly currentModel: QolModelInfo | undefined;
|
|
114
116
|
private screen: "search" | "messages" | "actions" | "confirmContext" | "confirmFork" | "confirmModel" = "search";
|
|
@@ -125,7 +127,7 @@ export class QolSessionSearchComponent {
|
|
|
125
127
|
done: (action: QolSessionPaletteAction) => void,
|
|
126
128
|
tui: { requestRender(): void; terminal?: { rows?: number } },
|
|
127
129
|
theme: Theme,
|
|
128
|
-
sessions:
|
|
130
|
+
sessions: QolSessionSearchLoad,
|
|
129
131
|
cwd: string,
|
|
130
132
|
initialQuery = "",
|
|
131
133
|
currentModel: QolModelInfo | undefined = undefined,
|
|
@@ -151,6 +153,13 @@ export class QolSessionSearchComponent {
|
|
|
151
153
|
|
|
152
154
|
invalidate(): void {}
|
|
153
155
|
|
|
156
|
+
/** Show the index once its load settles; the overlay opens before that. */
|
|
157
|
+
setSessions(sessions: QolSessionSearchLoad): void {
|
|
158
|
+
this.sessions = sessions;
|
|
159
|
+
this.updateResults(false);
|
|
160
|
+
this.tui.requestRender();
|
|
161
|
+
}
|
|
162
|
+
|
|
154
163
|
private maxOverlayRows(): number {
|
|
155
164
|
const terminalRows = Number(this.tui.terminal?.rows ?? process.stdout.rows ?? 30);
|
|
156
165
|
const safeRows = Number.isFinite(terminalRows) && terminalRows > 0 ? terminalRows : 30;
|
|
@@ -213,8 +222,9 @@ export class QolSessionSearchComponent {
|
|
|
213
222
|
}
|
|
214
223
|
|
|
215
224
|
private sessionsForScope(scope = this.searchState.scope): QolSessionSearchSession[] {
|
|
216
|
-
if (
|
|
217
|
-
|
|
225
|
+
if (this.sessions.status !== "ready") return [];
|
|
226
|
+
if (scope === "all") return this.sessions.sessions;
|
|
227
|
+
return this.sessions.sessions.filter((session) => sameSessionSearchProject(session.cwd, this.cwd));
|
|
218
228
|
}
|
|
219
229
|
|
|
220
230
|
private updateResults(resetSelection = true): void {
|
|
@@ -614,7 +624,11 @@ export class QolSessionSearchComponent {
|
|
|
614
624
|
if (!compact) lines.push(row(dim(`tokens · re:<pattern> regex · "phrase" exact`)));
|
|
615
625
|
lines.push(divider());
|
|
616
626
|
|
|
617
|
-
if (
|
|
627
|
+
if (this.sessions.status === "loading") {
|
|
628
|
+
lines.push(row(muted("Loading sessions…")));
|
|
629
|
+
} else if (this.sessions.status === "failed") {
|
|
630
|
+
lines.push(row(muted(`Could not load sessions: ${this.sessions.error.split("\n")[0]}`)));
|
|
631
|
+
} else if (state.results.length === 0) {
|
|
618
632
|
lines.push(row(muted(state.query.trim() ? "No prompts match your search" : "No prompts found")));
|
|
619
633
|
} else {
|
|
620
634
|
lines.push(...this.renderSearchHitPane(inner, row, dim, muted, accent));
|
|
@@ -5,9 +5,8 @@ import { settingNumber } from "../settings.js";
|
|
|
5
5
|
import { stringifyError } from "../util.js";
|
|
6
6
|
import {
|
|
7
7
|
getPendingSessionSearchMessage,
|
|
8
|
-
nextSessionSearchPendingActionId,
|
|
9
8
|
pinSessionModel,
|
|
10
|
-
|
|
9
|
+
queueSessionSearchPendingAction,
|
|
11
10
|
refreshQolSessionSearchCache,
|
|
12
11
|
sessionDisplayName,
|
|
13
12
|
setPendingSessionSearchMessage,
|
|
@@ -17,12 +16,14 @@ import {
|
|
|
17
16
|
buildSessionSearchContextMessageWithLoader,
|
|
18
17
|
injectSessionSearchContext,
|
|
19
18
|
} from "./context.js";
|
|
20
|
-
import type { QolSessionPaletteAction, QolSessionSearchResult } from "./types.js";
|
|
19
|
+
import type { QolSessionPaletteAction, QolSessionSearchLoad, QolSessionSearchResult } from "./types.js";
|
|
21
20
|
|
|
22
21
|
export {
|
|
23
|
-
|
|
22
|
+
clearSessionSearchPendingAction,
|
|
24
23
|
refreshQolSessionSearchCache,
|
|
24
|
+
releaseQolSessionSearchCache,
|
|
25
25
|
sessionSearchShortcut,
|
|
26
|
+
takeSessionSearchPendingAction,
|
|
26
27
|
} from "./cache.js";
|
|
27
28
|
export { renderSessionSearchContextMessage } from "./context.js";
|
|
28
29
|
|
|
@@ -31,8 +32,7 @@ function asCommandContext(ctx: ExtensionContext): (ExtensionContext & Partial<Ex
|
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
function queueSessionSearchCommandAction(ctx: ExtensionContext, action: QolSessionPaletteAction): void {
|
|
34
|
-
const id =
|
|
35
|
-
qolSessionSearchPendingActions.set(id, action);
|
|
35
|
+
const id = queueSessionSearchPendingAction(action);
|
|
36
36
|
ctx.ui.setEditorText(`/search:resume-pending ${id}`);
|
|
37
37
|
ctx.ui.notify(`${sessionDisplayName(action.result!)} — press enter to ${action.type === "fork" ? "fork" : "resume"}`, "info");
|
|
38
38
|
}
|
|
@@ -121,13 +121,21 @@ export async function openQolSessionSearch(pi: ExtensionAPI, ctx: ExtensionConte
|
|
|
121
121
|
ctx.ui.notify("Session search requires interactive UI", "warning");
|
|
122
122
|
return;
|
|
123
123
|
}
|
|
124
|
-
|
|
124
|
+
// The overlay opens at once and fills in when the index is loaded: a
|
|
125
|
+
// released index takes a full read of every session file to load again.
|
|
126
|
+
const loading: Promise<QolSessionSearchLoad> = refreshQolSessionSearchCache(ctx).then(
|
|
127
|
+
(sessions) => ({ status: "ready", sessions }),
|
|
128
|
+
(error) => ({ status: "failed", error: stringifyError(error) }),
|
|
129
|
+
);
|
|
125
130
|
const releaseModalLock = acquirekendexModalLock();
|
|
126
131
|
let action: QolSessionPaletteAction | undefined;
|
|
127
132
|
try {
|
|
128
133
|
const currentModel = ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined;
|
|
129
|
-
action = await ctx.ui.custom<QolSessionPaletteAction>((tui, theme, _keybindings, done) =>
|
|
130
|
-
new QolSessionSearchComponent(done, tui, theme,
|
|
134
|
+
action = await ctx.ui.custom<QolSessionPaletteAction>((tui, theme, _keybindings, done) => {
|
|
135
|
+
const component = new QolSessionSearchComponent(done, tui, theme, { status: "loading" }, ctx.cwd, initialQuery, currentModel);
|
|
136
|
+
void loading.then((load) => component.setSessions(load));
|
|
137
|
+
return component;
|
|
138
|
+
}, {
|
|
131
139
|
overlay: true,
|
|
132
140
|
overlayOptions: {
|
|
133
141
|
anchor: "center",
|
|
@@ -13,6 +13,13 @@ export interface QolSessionSearchSession {
|
|
|
13
13
|
path: string;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/** The session-search index as the overlay sees it: still loading, loaded,
|
|
17
|
+
* or failed with the load's error. */
|
|
18
|
+
export type QolSessionSearchLoad =
|
|
19
|
+
| { status: "loading" }
|
|
20
|
+
| { status: "ready"; sessions: QolSessionSearchSession[] }
|
|
21
|
+
| { status: "failed"; error: string };
|
|
22
|
+
|
|
16
23
|
export interface QolSessionSearchResult extends QolSessionSearchSession {
|
|
17
24
|
rank: number;
|
|
18
25
|
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
|
|
109
|
-
|
|
110
|
-
|
|
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
|
}
|
package/extensions/qol.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { pruneLanes } from "../scripts/lane-retention.js";
|
|
2
4
|
import { truncateToWidth, type TUI } from "@earendil-works/pi-tui";
|
|
3
5
|
import { criticalInfo, lastAssistantTextFromAgentEnd, needsDirection, taskStats } from "./qol/agent-end.js";
|
|
4
6
|
import { getQuestionService, readCavemanBridge, type QuestionOpenedEventLike } from "./qol/bridges.js";
|
|
@@ -16,6 +18,7 @@ import {
|
|
|
16
18
|
DEFAULT_IDLE_COMPACTION_SECONDS,
|
|
17
19
|
DEFAULT_INPUT_BOTTOM_PADDING_LINES,
|
|
18
20
|
INSTALL_SYMBOL,
|
|
21
|
+
QOL_BUDGET_HANDOFF_FOLDER,
|
|
19
22
|
QOL_NOTIFICATION_SERVICE_SYMBOL,
|
|
20
23
|
QUESTION_OPENED_EVENT,
|
|
21
24
|
SESSION_MANAGER_STATUS_KEY,
|
|
@@ -23,6 +26,7 @@ import {
|
|
|
23
26
|
SESSION_SEARCH_STATUS_KEY,
|
|
24
27
|
SESSION_TITLE_SYNC_INTERVAL_MS,
|
|
25
28
|
STATUS_KEY,
|
|
29
|
+
THINKING_TIMER_MAX_DURATIONS,
|
|
26
30
|
THINKING_TIMER_STORE_SYMBOL,
|
|
27
31
|
TMUX_SESSION_TITLE_BORDER_FORMAT,
|
|
28
32
|
} from "./qol/constants.js";
|
|
@@ -39,6 +43,7 @@ import { imageContentForPath, resolveSubmittedImagePaths } from "./qol/images.js
|
|
|
39
43
|
import {
|
|
40
44
|
clearTmuxWindowMark,
|
|
41
45
|
notifyQuestionOpened,
|
|
46
|
+
resetQolNotificationCooldowns,
|
|
42
47
|
sendQolNotification,
|
|
43
48
|
type QolNotificationService,
|
|
44
49
|
} from "./qol/notifications.js";
|
|
@@ -61,15 +66,18 @@ import {
|
|
|
61
66
|
} from "./qol/session-rename.js";
|
|
62
67
|
import { createScheduleController, getScheduleArgumentCompletions } from "./qol/schedule.js";
|
|
63
68
|
import {
|
|
69
|
+
clearSessionSearchPendingAction,
|
|
64
70
|
consumePendingSessionSearchContext,
|
|
65
71
|
openQolSessionSearch,
|
|
66
|
-
qolSessionSearchPendingActions,
|
|
67
72
|
refreshQolSessionSearchCache,
|
|
73
|
+
releaseQolSessionSearchCache,
|
|
68
74
|
renderSessionSearchContextMessage,
|
|
69
75
|
runSessionSearchResumeOrFork,
|
|
70
76
|
sessionSearchShortcut,
|
|
77
|
+
takeSessionSearchPendingAction,
|
|
71
78
|
} from "./qol/session-search/index.js";
|
|
72
|
-
import {
|
|
79
|
+
import { installSettingsCacheRefresh, piUserDir, recordProjectTrust } from "./qol/package-config.js";
|
|
80
|
+
import { settingBoolean, settingNumber, settingString } from "./qol/settings.js";
|
|
73
81
|
import { statusMessage } from "./qol/status-message.js";
|
|
74
82
|
import {
|
|
75
83
|
formatTmuxSessionTitle,
|
|
@@ -248,11 +256,24 @@ export default function qol(pi: ExtensionAPI): void {
|
|
|
248
256
|
const duration = Math.max(0, endTimeMs - start);
|
|
249
257
|
thinkingTimerStore.starts.delete(key);
|
|
250
258
|
thinkingTimerStore.durations.set(key, duration);
|
|
259
|
+
for (const oldest of thinkingTimerStore.durations.keys()) {
|
|
260
|
+
if (thinkingTimerStore.durations.size <= THINKING_TIMER_MAX_DURATIONS) break;
|
|
261
|
+
thinkingTimerStore.durations.delete(oldest);
|
|
262
|
+
}
|
|
251
263
|
const label = thinkingTimerStore.labels.get(key);
|
|
252
264
|
if (label) label.setText(thinkingTimerLabel(thinkingTimerStore.theme, duration, thinkingTimerStore.cwd));
|
|
253
265
|
if (thinkingTimerStore.starts.size === 0) stopThinkingTimerTicker();
|
|
254
266
|
};
|
|
255
267
|
|
|
268
|
+
// A label is held only to tick it while its block runs. A finished block's
|
|
269
|
+
// label already shows its final time, and a redraw of that message labels
|
|
270
|
+
// it again from `durations`.
|
|
271
|
+
const releaseFinishedThinkingLabels = () => {
|
|
272
|
+
for (const key of thinkingTimerStore.labels.keys()) {
|
|
273
|
+
if (!thinkingTimerStore.starts.has(key)) thinkingTimerStore.labels.delete(key);
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
256
277
|
const clearIdleCompactionTimer = () => {
|
|
257
278
|
if (idleCompactionTimer) clearTimeout(idleCompactionTimer);
|
|
258
279
|
idleCompactionTimer = undefined;
|
|
@@ -586,8 +607,13 @@ export default function qol(pi: ExtensionAPI): void {
|
|
|
586
607
|
rateLimitAutoResumeController.noteExternalRateLimitEvent(payload, currentCtx);
|
|
587
608
|
});
|
|
588
609
|
|
|
610
|
+
installSettingsCacheRefresh(pi);
|
|
589
611
|
pi.on("session_start", (event, ctx) => {
|
|
590
612
|
recordProjectTrust(ctx);
|
|
613
|
+
const pruned = pruneLanes(join(piUserDir(), "kendex", "sessions"), QOL_BUDGET_HANDOFF_FOLDER.split("/"));
|
|
614
|
+
for (const failure of pruned.failed) {
|
|
615
|
+
console.warn(`handoff-prune-error: ${failure.path}\n${failure.error}`);
|
|
616
|
+
}
|
|
591
617
|
currentCtx = ctx;
|
|
592
618
|
subscribeCavemanBridge();
|
|
593
619
|
latestSystemPromptOptions = undefined;
|
|
@@ -648,7 +674,8 @@ export default function qol(pi: ExtensionAPI): void {
|
|
|
648
674
|
}
|
|
649
675
|
startQuestionSubscription(ctx);
|
|
650
676
|
void attemptAutoRename(ctx);
|
|
651
|
-
|
|
677
|
+
// A headless session has no search overlay to open, so it loads no index.
|
|
678
|
+
if (ctx.hasUI && settingBoolean("sessionSearch.enabled", true, ctx.cwd)) {
|
|
652
679
|
if (sessionSearchWarmupTimer) clearTimeout(sessionSearchWarmupTimer);
|
|
653
680
|
sessionSearchWarmupTimer = setTimeout(() => {
|
|
654
681
|
sessionSearchWarmupTimer = undefined;
|
|
@@ -673,8 +700,11 @@ export default function qol(pi: ExtensionAPI): void {
|
|
|
673
700
|
clearQuestionSubscribeTimer();
|
|
674
701
|
if (sessionSearchWarmupTimer) clearTimeout(sessionSearchWarmupTimer);
|
|
675
702
|
sessionSearchWarmupTimer = undefined;
|
|
703
|
+
releaseQolSessionSearchCache();
|
|
704
|
+
clearSessionSearchPendingAction();
|
|
676
705
|
resetThinkingTimer(undefined);
|
|
677
706
|
clearTmuxWindowMark(pi);
|
|
707
|
+
resetQolNotificationCooldowns();
|
|
678
708
|
questionUnsubscribe?.();
|
|
679
709
|
questionUnsubscribe = undefined;
|
|
680
710
|
currentCtx = undefined;
|
|
@@ -744,6 +774,7 @@ export default function qol(pi: ExtensionAPI): void {
|
|
|
744
774
|
}
|
|
745
775
|
});
|
|
746
776
|
pi.on("agent_end", (event, ctx) => {
|
|
777
|
+
releaseFinishedThinkingLabels();
|
|
747
778
|
if (ctx.hasUI) {
|
|
748
779
|
void refreshStatusline(ctx);
|
|
749
780
|
requestRender();
|
|
@@ -903,13 +934,11 @@ export default function qol(pi: ExtensionAPI): void {
|
|
|
903
934
|
pi.registerCommand("search:resume-pending", {
|
|
904
935
|
description: "Run a pending session-search resume or fork action",
|
|
905
936
|
handler: async (args, ctx) => {
|
|
906
|
-
const
|
|
907
|
-
const action = qolSessionSearchPendingActions.get(id);
|
|
937
|
+
const action = takeSessionSearchPendingAction(args.trim());
|
|
908
938
|
if (!action) {
|
|
909
939
|
ctx.ui.notify("No pending session-search resume/fork action found.", "warning");
|
|
910
940
|
return;
|
|
911
941
|
}
|
|
912
|
-
qolSessionSearchPendingActions.delete(id);
|
|
913
942
|
if (!(await runSessionSearchResumeOrFork(pi, ctx, action))) ctx.ui.notify("Session resume/fork is unavailable in this context.", "error");
|
|
914
943
|
},
|
|
915
944
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vanillagreen/pi-qol",
|
|
3
|
-
"version": "2.2
|
|
3
|
+
"version": "2.3.2",
|
|
4
4
|
"description": "Pi quality-of-life extension: compact statusline/π prompt, reliable multiline input, styled pasted-image chips, session naming/search/context import, scheduled prompts, handoff, permission prompts, notifications, custom compaction, and a collapsed-thinking timer.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -504,9 +504,9 @@
|
|
|
504
504
|
{
|
|
505
505
|
"key": "sessionSearch.cacheTtlSeconds",
|
|
506
506
|
"label": "Session cache TTL",
|
|
507
|
-
"description": "Seconds
|
|
507
|
+
"description": "Seconds a loaded session index is kept in memory before it is released; the next search opens at once and reloads it. 0 keeps the index until the session ends.",
|
|
508
508
|
"type": "number",
|
|
509
|
-
"default":
|
|
509
|
+
"default": 300,
|
|
510
510
|
"category": "Session Search",
|
|
511
511
|
"apply": "live"
|
|
512
512
|
},
|
|
@@ -988,6 +988,7 @@
|
|
|
988
988
|
},
|
|
989
989
|
"files": [
|
|
990
990
|
"extensions/",
|
|
991
|
+
"scripts/",
|
|
991
992
|
"assets/",
|
|
992
993
|
"README.md",
|
|
993
994
|
"package.json"
|