@pi-unipi/utility 0.1.1 → 0.2.3
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 +135 -21
- package/package.json +16 -7
- package/skills/utility/SKILL.md +70 -0
- package/src/analytics/collector.ts +293 -0
- package/src/cache/ttl-cache.ts +311 -0
- package/src/commands.ts +250 -0
- package/src/diagnostics/engine.ts +298 -0
- package/src/display/capabilities.ts +200 -0
- package/src/display/width.ts +226 -0
- package/src/index.ts +283 -0
- package/src/info-screen.ts +80 -0
- package/src/lifecycle/cleanup.ts +332 -0
- package/src/lifecycle/process.ts +162 -0
- package/src/tools/batch.ts +229 -0
- package/src/tools/env.ts +134 -0
- package/src/tui/badge-settings.ts +103 -0
- package/src/tui/name-badge-state.ts +299 -0
- package/src/tui/name-badge.ts +117 -0
- package/src/tui/settings-inspector.ts +303 -0
- package/src/types.ts +257 -0
- package/commands.ts +0 -38
- package/index.ts +0 -34
package/src/index.ts
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/utility — Extension entry
|
|
3
|
+
*
|
|
4
|
+
* Comprehensive utilities suite for Pi coding agent:
|
|
5
|
+
* - Commands: continue, reload, status, cleanup, env, doctor, badge
|
|
6
|
+
* - Tools: ctx_batch, ctx_env, set_session_name
|
|
7
|
+
* - Lifecycle: process management, stale cleanup
|
|
8
|
+
* - Cache: TTL cache with optional persistence
|
|
9
|
+
* - Analytics: lightweight event collection
|
|
10
|
+
* - Diagnostics: cross-module health checks
|
|
11
|
+
* - Display: terminal capabilities, width utilities
|
|
12
|
+
* - TUI: settings inspector pattern, name badge
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
16
|
+
import {
|
|
17
|
+
UNIPI_EVENTS,
|
|
18
|
+
MODULES,
|
|
19
|
+
UTILITY_COMMANDS,
|
|
20
|
+
UTILITY_TOOLS,
|
|
21
|
+
emitEvent,
|
|
22
|
+
getPackageVersion,
|
|
23
|
+
type UnipiBadgeGenerateRequestEvent,
|
|
24
|
+
} from "@pi-unipi/core";
|
|
25
|
+
import { registerUtilityCommands, registerNameBadgeCommands } from "./commands.js";
|
|
26
|
+
import { NameBadgeState } from "./tui/name-badge-state.js";
|
|
27
|
+
import { readBadgeSettings } from "./tui/badge-settings.js";
|
|
28
|
+
import { getLifecycle } from "./lifecycle/process.js";
|
|
29
|
+
import { getAnalyticsCollector } from "./analytics/collector.js";
|
|
30
|
+
import { registerInfoScreen } from "./info-screen.js";
|
|
31
|
+
|
|
32
|
+
/** Package version */
|
|
33
|
+
const VERSION = getPackageVersion(new URL(".", import.meta.url).pathname);
|
|
34
|
+
|
|
35
|
+
/** Whether we've seen the first user message (for auto badge generation) */
|
|
36
|
+
let firstMessageSeen = false;
|
|
37
|
+
|
|
38
|
+
/** All commands registered by this module */
|
|
39
|
+
const ALL_COMMANDS = [
|
|
40
|
+
UTILITY_COMMANDS.CONTINUE,
|
|
41
|
+
UTILITY_COMMANDS.RELOAD,
|
|
42
|
+
UTILITY_COMMANDS.STATUS,
|
|
43
|
+
UTILITY_COMMANDS.CLEANUP,
|
|
44
|
+
UTILITY_COMMANDS.ENV,
|
|
45
|
+
UTILITY_COMMANDS.DOCTOR,
|
|
46
|
+
UTILITY_COMMANDS.NAME_BADGE,
|
|
47
|
+
UTILITY_COMMANDS.BADGE_GEN,
|
|
48
|
+
UTILITY_COMMANDS.BADGE_TOGGLE,
|
|
49
|
+
].map((cmd) => `unipi:${cmd}`);
|
|
50
|
+
|
|
51
|
+
/** All tools registered by this module */
|
|
52
|
+
const ALL_TOOLS = [UTILITY_TOOLS.BATCH, UTILITY_TOOLS.ENV, UTILITY_TOOLS.SET_SESSION_NAME];
|
|
53
|
+
|
|
54
|
+
export default function (pi: ExtensionAPI) {
|
|
55
|
+
// Initialize lifecycle manager
|
|
56
|
+
const lifecycle = getLifecycle();
|
|
57
|
+
|
|
58
|
+
// Initialize analytics collector
|
|
59
|
+
const analytics = getAnalyticsCollector();
|
|
60
|
+
|
|
61
|
+
// Register cleanup on shutdown
|
|
62
|
+
lifecycle.registerCleanup(async () => {
|
|
63
|
+
analytics.disable();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Initialize name badge state
|
|
67
|
+
const nameBadgeState = new NameBadgeState();
|
|
68
|
+
|
|
69
|
+
// Register commands
|
|
70
|
+
registerUtilityCommands(pi);
|
|
71
|
+
registerNameBadgeCommands(pi, nameBadgeState);
|
|
72
|
+
|
|
73
|
+
// Register tools
|
|
74
|
+
registerUtilityTools(pi, nameBadgeState);
|
|
75
|
+
|
|
76
|
+
// Register info-screen group
|
|
77
|
+
registerInfoScreen(pi);
|
|
78
|
+
|
|
79
|
+
// Session lifecycle — announce module + restore badge
|
|
80
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
81
|
+
emitEvent(pi, UNIPI_EVENTS.MODULE_READY, {
|
|
82
|
+
name: MODULES.UTILITY,
|
|
83
|
+
version: VERSION,
|
|
84
|
+
commands: ALL_COMMANDS,
|
|
85
|
+
tools: ALL_TOOLS,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
analytics.recordModuleLoad(MODULES.UTILITY, VERSION);
|
|
89
|
+
|
|
90
|
+
// Restore name badge if it was visible in previous session
|
|
91
|
+
await nameBadgeState.restore(pi, ctx);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// First-message hook: auto-generate session name on first user message
|
|
95
|
+
pi.on("input", async (_event: any, ctx: any) => {
|
|
96
|
+
// Only trigger on first user message
|
|
97
|
+
if (firstMessageSeen) return;
|
|
98
|
+
firstMessageSeen = true;
|
|
99
|
+
|
|
100
|
+
// Check if auto generation is enabled
|
|
101
|
+
const settings = readBadgeSettings();
|
|
102
|
+
if (!settings.autoGen) return;
|
|
103
|
+
|
|
104
|
+
// Skip if badge already has a name
|
|
105
|
+
const sessionName = pi.getSessionName?.();
|
|
106
|
+
if (sessionName) return;
|
|
107
|
+
|
|
108
|
+
// Get first message text for context
|
|
109
|
+
const messageText = typeof _event?.content === "string"
|
|
110
|
+
? _event.content
|
|
111
|
+
: Array.isArray(_event?.content)
|
|
112
|
+
? _event.content
|
|
113
|
+
.filter((c: any) => c.type === "text")
|
|
114
|
+
.map((c: any) => c.text)
|
|
115
|
+
.join(" ")
|
|
116
|
+
: "";
|
|
117
|
+
|
|
118
|
+
// Emit event for subagents to spawn background agent
|
|
119
|
+
emitEvent(pi, UNIPI_EVENTS.BADGE_GENERATE_REQUEST, {
|
|
120
|
+
source: "input-hook",
|
|
121
|
+
conversationSummary: messageText.slice(0, 500),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Show badge overlay if UI available
|
|
125
|
+
if (ctx?.hasUI && !nameBadgeState.isVisible()) {
|
|
126
|
+
await nameBadgeState.show(pi, ctx);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Listen for badge generation requests from other modules (e.g., kanboard)
|
|
131
|
+
pi.on(UNIPI_EVENTS.BADGE_GENERATE_REQUEST as any, async (_event: any, ctx: any) => {
|
|
132
|
+
// Show badge overlay if not already visible
|
|
133
|
+
if (!nameBadgeState.isVisible() && ctx?.hasUI) {
|
|
134
|
+
await nameBadgeState.show(pi, ctx);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Track command usage
|
|
139
|
+
pi.on("tool_call", async (event) => {
|
|
140
|
+
if (event.toolName.startsWith("unipi:")) {
|
|
141
|
+
analytics.recordCommand(event.toolName, MODULES.UTILITY, 0, true);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Session shutdown cleanup
|
|
146
|
+
pi.on("session_shutdown", async () => {
|
|
147
|
+
nameBadgeState.hide();
|
|
148
|
+
firstMessageSeen = false;
|
|
149
|
+
await lifecycle.shutdown("session_shutdown");
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Register utility tools.
|
|
155
|
+
*/
|
|
156
|
+
function registerUtilityTools(pi: ExtensionAPI, nameBadgeState: NameBadgeState): void {
|
|
157
|
+
// ctx_batch — atomic batch execution
|
|
158
|
+
pi.registerTool({
|
|
159
|
+
name: UTILITY_TOOLS.BATCH,
|
|
160
|
+
label: "Batch Execute",
|
|
161
|
+
description:
|
|
162
|
+
"Execute a batch of commands atomically with rollback support. " +
|
|
163
|
+
"Accepts an array of {type, name, args} objects. " +
|
|
164
|
+
"Options: failFast (default true), commandTimeoutMs, totalTimeoutMs.",
|
|
165
|
+
promptSnippet: "Run multiple commands as an atomic batch.",
|
|
166
|
+
parameters: {
|
|
167
|
+
type: "object",
|
|
168
|
+
properties: {
|
|
169
|
+
commands: {
|
|
170
|
+
type: "array",
|
|
171
|
+
items: {
|
|
172
|
+
type: "object",
|
|
173
|
+
properties: {
|
|
174
|
+
type: { type: "string", enum: ["command", "tool", "search"] },
|
|
175
|
+
name: { type: "string" },
|
|
176
|
+
args: { type: "object" },
|
|
177
|
+
},
|
|
178
|
+
required: ["type", "name"],
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
options: {
|
|
182
|
+
type: "object",
|
|
183
|
+
properties: {
|
|
184
|
+
failFast: { type: "boolean" },
|
|
185
|
+
commandTimeoutMs: { type: "number" },
|
|
186
|
+
totalTimeoutMs: { type: "number" },
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
required: ["commands"],
|
|
191
|
+
},
|
|
192
|
+
async execute(_toolCallId, params) {
|
|
193
|
+
const { commands, options } = params as unknown as {
|
|
194
|
+
commands: Array<{ type: string; name: string; args?: Record<string, unknown> }>;
|
|
195
|
+
options?: Record<string, unknown>;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// Tool implementation delegates to batch executor
|
|
199
|
+
// The actual executor must be provided by the host
|
|
200
|
+
return {
|
|
201
|
+
content: [
|
|
202
|
+
{
|
|
203
|
+
type: "text",
|
|
204
|
+
text:
|
|
205
|
+
"ctx_batch requires a command executor from the host environment. " +
|
|
206
|
+
`Received ${commands.length} commands. ` +
|
|
207
|
+
"Use BatchBuilder or executeBatch() directly in code.",
|
|
208
|
+
},
|
|
209
|
+
],
|
|
210
|
+
details: { commands, options },
|
|
211
|
+
};
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// ctx_env — environment info
|
|
216
|
+
pi.registerTool({
|
|
217
|
+
name: UTILITY_TOOLS.ENV,
|
|
218
|
+
label: "Environment Info",
|
|
219
|
+
description: "Show environment information: Node version, Pi version, OS, unipi modules, config paths.",
|
|
220
|
+
promptSnippet: "Get environment details for debugging.",
|
|
221
|
+
parameters: {
|
|
222
|
+
type: "object",
|
|
223
|
+
properties: {},
|
|
224
|
+
},
|
|
225
|
+
async execute() {
|
|
226
|
+
const { getEnvironmentInfo, formatEnvironmentInfo } = await import("./tools/env.js");
|
|
227
|
+
const info = getEnvironmentInfo();
|
|
228
|
+
return {
|
|
229
|
+
content: [{ type: "text", text: formatEnvironmentInfo(info) }],
|
|
230
|
+
details: info,
|
|
231
|
+
};
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// set_session_name — set the session name for badge display
|
|
236
|
+
const badgeSettings = readBadgeSettings();
|
|
237
|
+
if (badgeSettings.agentTool) {
|
|
238
|
+
pi.registerTool({
|
|
239
|
+
name: UTILITY_TOOLS.SET_SESSION_NAME,
|
|
240
|
+
label: "Set Session Name",
|
|
241
|
+
description:
|
|
242
|
+
"Set the session name that appears in the badge overlay and session selector. " +
|
|
243
|
+
"Use this to give the current session a descriptive title. " +
|
|
244
|
+
"Name should be concise (max 5 words recommended).",
|
|
245
|
+
promptSnippet: "Set a name/title for the current session.",
|
|
246
|
+
parameters: {
|
|
247
|
+
type: "object",
|
|
248
|
+
properties: {
|
|
249
|
+
name: {
|
|
250
|
+
type: "string",
|
|
251
|
+
description: "The session name to set (max 5 words recommended).",
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
required: ["name"],
|
|
255
|
+
},
|
|
256
|
+
async execute(_toolCallId, params) {
|
|
257
|
+
const { name } = params as { name: string };
|
|
258
|
+
if (!name || typeof name !== "string") {
|
|
259
|
+
return {
|
|
260
|
+
content: [{ type: "text", text: "Error: name parameter is required and must be a string." }],
|
|
261
|
+
details: undefined,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const trimmed = name.trim();
|
|
266
|
+
if (trimmed.length === 0) {
|
|
267
|
+
return {
|
|
268
|
+
content: [{ type: "text", text: "Error: name cannot be empty." }],
|
|
269
|
+
details: undefined,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Set the session name
|
|
274
|
+
nameBadgeState.setSessionName(pi, trimmed);
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
content: [{ type: "text", text: `Session name set to: "${trimmed}"` }],
|
|
278
|
+
details: { name: trimmed },
|
|
279
|
+
};
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/utility — Info-Screen Integration
|
|
3
|
+
*
|
|
4
|
+
* Registers utility stats group for the info-screen overlay.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
8
|
+
import {
|
|
9
|
+
UNIPI_EVENTS,
|
|
10
|
+
MODULES,
|
|
11
|
+
emitEvent,
|
|
12
|
+
} from "@pi-unipi/core";
|
|
13
|
+
import { getLifecycle } from "./lifecycle/process.js";
|
|
14
|
+
import { getAnalyticsCollector } from "./analytics/collector.js";
|
|
15
|
+
|
|
16
|
+
/** Info group ID */
|
|
17
|
+
const GROUP_ID = "utility";
|
|
18
|
+
|
|
19
|
+
/** Info group display name */
|
|
20
|
+
const GROUP_NAME = "Utility";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Register the utility info-screen group.
|
|
24
|
+
*/
|
|
25
|
+
export function registerInfoScreen(pi: ExtensionAPI): void {
|
|
26
|
+
// Announce group registration
|
|
27
|
+
emitEvent(pi, UNIPI_EVENTS.INFO_GROUP_REGISTERED, {
|
|
28
|
+
groupId: GROUP_ID,
|
|
29
|
+
groupName: GROUP_NAME,
|
|
30
|
+
module: MODULES.UTILITY,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Listen for status requests
|
|
34
|
+
pi.on("session_start", async () => {
|
|
35
|
+
// Module is ready — data will be served on demand
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Get current utility stats for info-screen display.
|
|
41
|
+
*/
|
|
42
|
+
export function getUtilityStats(): Record<string, unknown> {
|
|
43
|
+
const lifecycle = getLifecycle();
|
|
44
|
+
const analytics = getAnalyticsCollector();
|
|
45
|
+
|
|
46
|
+
const events = analytics.getEvents();
|
|
47
|
+
const rollup = analytics.getRollup();
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
uptime: process.uptime(),
|
|
51
|
+
state: lifecycle.currentState,
|
|
52
|
+
isOrphaned: lifecycle.isOrphaned,
|
|
53
|
+
eventsToday: Object.values(rollup.events).reduce((a, b) => a + b, 0),
|
|
54
|
+
errorsToday: rollup.errorCount,
|
|
55
|
+
totalEvents: events.length,
|
|
56
|
+
nodeVersion: process.version,
|
|
57
|
+
platform: process.platform,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Format utility stats as markdown for display.
|
|
63
|
+
*/
|
|
64
|
+
export function formatUtilityStats(stats: Record<string, unknown>): string {
|
|
65
|
+
const lines = [
|
|
66
|
+
`**State:** ${stats.state}`,
|
|
67
|
+
`**Uptime:** ${Math.round((stats.uptime as number) / 60)}m`,
|
|
68
|
+
`**Events today:** ${stats.eventsToday}`,
|
|
69
|
+
`**Errors today:** ${stats.errorsToday}`,
|
|
70
|
+
`**Total events:** ${stats.totalEvents}`,
|
|
71
|
+
`**Node:** ${stats.nodeVersion}`,
|
|
72
|
+
`**Platform:** ${stats.platform}`,
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
if (stats.isOrphaned) {
|
|
76
|
+
lines.push("⚠️ **Orphaned process detected**");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return lines.join("\n");
|
|
80
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/utility — Stale Cleanup Utility
|
|
3
|
+
*
|
|
4
|
+
* Cleans stale DBs, temp files, old sessions across all unipi modules.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, statSync, readdirSync, unlinkSync, rmdirSync } from "node:fs";
|
|
8
|
+
import { join, resolve, basename } from "node:path";
|
|
9
|
+
import { homedir, tmpdir } from "node:os";
|
|
10
|
+
import type { CleanupReport, CleanupResult, CleanupOptions } from "../types.js";
|
|
11
|
+
|
|
12
|
+
/** Default options */
|
|
13
|
+
const DEFAULTS: Required<CleanupOptions> = {
|
|
14
|
+
dbMaxAgeDays: 14,
|
|
15
|
+
tempMaxAgeDays: 7,
|
|
16
|
+
sessionMaxAgeDays: 30,
|
|
17
|
+
dryRun: false,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Expand ~ to home directory */
|
|
21
|
+
function expandHome(path: string): string {
|
|
22
|
+
if (path.startsWith("~/")) {
|
|
23
|
+
return join(homedir(), path.slice(2));
|
|
24
|
+
}
|
|
25
|
+
return path;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Check if a file is older than maxAgeDays */
|
|
29
|
+
function isStale(path: string, maxAgeDays: number): boolean {
|
|
30
|
+
try {
|
|
31
|
+
const stats = statSync(path);
|
|
32
|
+
const ageMs = Date.now() - stats.mtime.getTime();
|
|
33
|
+
return ageMs > maxAgeDays * 24 * 60 * 60 * 1000;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Try to detect if a DB file has a zombie WAL lock */
|
|
40
|
+
function hasWalLock(dbPath: string): boolean {
|
|
41
|
+
const walPath = dbPath + "-wal";
|
|
42
|
+
const shmPath = dbPath + "-shm";
|
|
43
|
+
// If WAL exists but journal mode isn't WAL, or WAL is very old, it's stale
|
|
44
|
+
if (existsSync(walPath)) {
|
|
45
|
+
try {
|
|
46
|
+
const walStats = statSync(walPath);
|
|
47
|
+
const ageMs = Date.now() - walStats.mtime.getTime();
|
|
48
|
+
return ageMs > 5 * 60 * 1000; // 5 min = stale WAL
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Clean stale database files in ~/.unipi/ */
|
|
57
|
+
function cleanDbs(options: Required<CleanupOptions>): CleanupResult {
|
|
58
|
+
const result: CleanupResult = {
|
|
59
|
+
category: "db",
|
|
60
|
+
removed: 0,
|
|
61
|
+
bytesFreed: 0,
|
|
62
|
+
paths: [],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const unipiDir = expandHome("~/.unipi");
|
|
66
|
+
if (!existsSync(unipiDir)) return result;
|
|
67
|
+
|
|
68
|
+
const scanDir = (dir: string) => {
|
|
69
|
+
let entries: string[];
|
|
70
|
+
try {
|
|
71
|
+
entries = readdirSync(dir);
|
|
72
|
+
} catch {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
const fullPath = join(dir, entry);
|
|
78
|
+
try {
|
|
79
|
+
const stats = statSync(fullPath);
|
|
80
|
+
if (stats.isDirectory()) {
|
|
81
|
+
scanDir(fullPath);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Match SQLite DB files
|
|
86
|
+
if (
|
|
87
|
+
entry.endsWith(".db") ||
|
|
88
|
+
entry.endsWith(".sqlite") ||
|
|
89
|
+
entry.endsWith(".sqlite3")
|
|
90
|
+
) {
|
|
91
|
+
if (isStale(fullPath, options.dbMaxAgeDays) || hasWalLock(fullPath)) {
|
|
92
|
+
result.bytesFreed += stats.size;
|
|
93
|
+
result.paths.push(fullPath);
|
|
94
|
+
if (!options.dryRun) {
|
|
95
|
+
try {
|
|
96
|
+
unlinkSync(fullPath);
|
|
97
|
+
// Also clean WAL/SHM companions
|
|
98
|
+
for (const suffix of ["-wal", "-shm", "-journal"]) {
|
|
99
|
+
const companion = fullPath + suffix;
|
|
100
|
+
if (existsSync(companion)) {
|
|
101
|
+
unlinkSync(companion);
|
|
102
|
+
result.bytesFreed += statSync(companion).size;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
result.removed++;
|
|
106
|
+
} catch {
|
|
107
|
+
// Best effort
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
result.removed++;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// Skip unreadable entries
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
scanDir(unipiDir);
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Clean temp files matching unipi patterns */
|
|
125
|
+
function cleanTemps(options: Required<CleanupOptions>): CleanupResult {
|
|
126
|
+
const result: CleanupResult = {
|
|
127
|
+
category: "temp",
|
|
128
|
+
removed: 0,
|
|
129
|
+
bytesFreed: 0,
|
|
130
|
+
paths: [],
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const patterns = [/^unipi-/, /^pi-/, /\.unipi\./];
|
|
134
|
+
const tmpDir = tmpdir();
|
|
135
|
+
|
|
136
|
+
let entries: string[];
|
|
137
|
+
try {
|
|
138
|
+
entries = readdirSync(tmpDir);
|
|
139
|
+
} catch {
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const entry of entries) {
|
|
144
|
+
if (!patterns.some((p) => p.test(entry))) continue;
|
|
145
|
+
|
|
146
|
+
const fullPath = join(tmpDir, entry);
|
|
147
|
+
try {
|
|
148
|
+
const stats = statSync(fullPath);
|
|
149
|
+
if (!stats.isFile()) continue;
|
|
150
|
+
|
|
151
|
+
if (isStale(fullPath, options.tempMaxAgeDays)) {
|
|
152
|
+
result.bytesFreed += stats.size;
|
|
153
|
+
result.paths.push(fullPath);
|
|
154
|
+
if (!options.dryRun) {
|
|
155
|
+
try {
|
|
156
|
+
unlinkSync(fullPath);
|
|
157
|
+
result.removed++;
|
|
158
|
+
} catch {
|
|
159
|
+
// Best effort
|
|
160
|
+
}
|
|
161
|
+
} else {
|
|
162
|
+
result.removed++;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} catch {
|
|
166
|
+
// Skip unreadable
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Clean stale session directories */
|
|
174
|
+
function cleanSessions(options: Required<CleanupOptions>): CleanupResult {
|
|
175
|
+
const result: CleanupResult = {
|
|
176
|
+
category: "session",
|
|
177
|
+
removed: 0,
|
|
178
|
+
bytesFreed: 0,
|
|
179
|
+
paths: [],
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const unipiDir = expandHome("~/.unipi");
|
|
183
|
+
const sessionsDir = join(unipiDir, "sessions");
|
|
184
|
+
if (!existsSync(sessionsDir)) return result;
|
|
185
|
+
|
|
186
|
+
let entries: string[];
|
|
187
|
+
try {
|
|
188
|
+
entries = readdirSync(sessionsDir);
|
|
189
|
+
} catch {
|
|
190
|
+
return result;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
for (const entry of entries) {
|
|
194
|
+
const fullPath = join(sessionsDir, entry);
|
|
195
|
+
try {
|
|
196
|
+
const stats = statSync(fullPath);
|
|
197
|
+
if (!stats.isDirectory()) continue;
|
|
198
|
+
|
|
199
|
+
if (isStale(fullPath, options.sessionMaxAgeDays)) {
|
|
200
|
+
result.bytesFreed += stats.size;
|
|
201
|
+
result.paths.push(fullPath);
|
|
202
|
+
if (!options.dryRun) {
|
|
203
|
+
try {
|
|
204
|
+
// Remove directory contents then directory
|
|
205
|
+
const removeRecursive = (dir: string) => {
|
|
206
|
+
const items = readdirSync(dir);
|
|
207
|
+
for (const item of items) {
|
|
208
|
+
const itemPath = join(dir, item);
|
|
209
|
+
const itemStats = statSync(itemPath);
|
|
210
|
+
if (itemStats.isDirectory()) {
|
|
211
|
+
removeRecursive(itemPath);
|
|
212
|
+
} else {
|
|
213
|
+
unlinkSync(itemPath);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
rmdirSync(dir);
|
|
217
|
+
};
|
|
218
|
+
removeRecursive(fullPath);
|
|
219
|
+
result.removed++;
|
|
220
|
+
} catch {
|
|
221
|
+
// Best effort
|
|
222
|
+
}
|
|
223
|
+
} else {
|
|
224
|
+
result.removed++;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
} catch {
|
|
228
|
+
// Skip unreadable
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return result;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Clean stale cache files */
|
|
236
|
+
function cleanCache(options: Required<CleanupOptions>): CleanupResult {
|
|
237
|
+
const result: CleanupResult = {
|
|
238
|
+
category: "cache",
|
|
239
|
+
removed: 0,
|
|
240
|
+
bytesFreed: 0,
|
|
241
|
+
paths: [],
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const cacheDir = expandHome("~/.unipi/cache");
|
|
245
|
+
if (!existsSync(cacheDir)) return result;
|
|
246
|
+
|
|
247
|
+
let entries: string[];
|
|
248
|
+
try {
|
|
249
|
+
entries = readdirSync(cacheDir);
|
|
250
|
+
} catch {
|
|
251
|
+
return result;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
for (const entry of entries) {
|
|
255
|
+
const fullPath = join(cacheDir, entry);
|
|
256
|
+
try {
|
|
257
|
+
const stats = statSync(fullPath);
|
|
258
|
+
if (!stats.isFile()) continue;
|
|
259
|
+
|
|
260
|
+
if (isStale(fullPath, options.tempMaxAgeDays)) {
|
|
261
|
+
result.bytesFreed += stats.size;
|
|
262
|
+
result.paths.push(fullPath);
|
|
263
|
+
if (!options.dryRun) {
|
|
264
|
+
try {
|
|
265
|
+
unlinkSync(fullPath);
|
|
266
|
+
result.removed++;
|
|
267
|
+
} catch {
|
|
268
|
+
// Best effort
|
|
269
|
+
}
|
|
270
|
+
} else {
|
|
271
|
+
result.removed++;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
} catch {
|
|
275
|
+
// Skip unreadable
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return result;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Run full cleanup of stale files across all unipi modules.
|
|
284
|
+
*/
|
|
285
|
+
export function cleanupStale(options: CleanupOptions = {}): CleanupReport {
|
|
286
|
+
const opts: Required<CleanupOptions> = { ...DEFAULTS, ...options };
|
|
287
|
+
|
|
288
|
+
const results: CleanupResult[] = [
|
|
289
|
+
cleanDbs(opts),
|
|
290
|
+
cleanTemps(opts),
|
|
291
|
+
cleanSessions(opts),
|
|
292
|
+
cleanCache(opts),
|
|
293
|
+
];
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
timestamp: Date.now(),
|
|
297
|
+
results,
|
|
298
|
+
totalRemoved: results.reduce((sum, r) => sum + r.removed, 0),
|
|
299
|
+
totalBytesFreed: results.reduce((sum, r) => sum + r.bytesFreed, 0),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Format a cleanup report as markdown */
|
|
304
|
+
export function formatCleanupReport(report: CleanupReport): string {
|
|
305
|
+
const lines = [
|
|
306
|
+
"## 🧹 Cleanup Report",
|
|
307
|
+
"",
|
|
308
|
+
`**Total removed:** ${report.totalRemoved} items`,
|
|
309
|
+
`**Space freed:** ${(report.totalBytesFreed / 1024 / 1024).toFixed(2)} MB`,
|
|
310
|
+
`**Timestamp:** ${new Date(report.timestamp).toISOString()}`,
|
|
311
|
+
"",
|
|
312
|
+
];
|
|
313
|
+
|
|
314
|
+
for (const result of report.results) {
|
|
315
|
+
if (result.removed === 0) continue;
|
|
316
|
+
lines.push(
|
|
317
|
+
`### ${result.category.toUpperCase()}`,
|
|
318
|
+
`- Removed: ${result.removed}`,
|
|
319
|
+
`- Freed: ${(result.bytesFreed / 1024).toFixed(1)} KB`,
|
|
320
|
+
"",
|
|
321
|
+
);
|
|
322
|
+
for (const path of result.paths.slice(0, 10)) {
|
|
323
|
+
lines.push(`- \`${path}\``);
|
|
324
|
+
}
|
|
325
|
+
if (result.paths.length > 10) {
|
|
326
|
+
lines.push(`- ... and ${result.paths.length - 10} more`);
|
|
327
|
+
}
|
|
328
|
+
lines.push("");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return lines.join("\n");
|
|
332
|
+
}
|