@code-fixer-23/pi-session-manager 1.0.0
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/CHANGELOG.md +26 -0
- package/LICENSE +21 -0
- package/README.md +76 -0
- package/assets/Pi-Session-Manager-Small.png +0 -0
- package/assets/Pi-Session_Mangager-Big.png +0 -0
- package/extensions/index.test.ts +1042 -0
- package/extensions/index.ts +966 -0
- package/package.json +37 -0
- package/project.json +64 -0
- package/scripts/create-extension.ts +18 -0
- package/tsconfig.json +27 -0
- package/vitest.config.ts +30 -0
|
@@ -0,0 +1,966 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ExtensionAPI,
|
|
3
|
+
type ExtensionCommandContext,
|
|
4
|
+
getAgentDir,
|
|
5
|
+
type SessionEntry,
|
|
6
|
+
type SessionInfo,
|
|
7
|
+
SessionManager,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
10
|
+
import {
|
|
11
|
+
existsSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
rmSync,
|
|
14
|
+
unlinkSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from "fs";
|
|
17
|
+
import { tmpdir } from "os";
|
|
18
|
+
import { join } from "path";
|
|
19
|
+
import {
|
|
20
|
+
array,
|
|
21
|
+
checkItems,
|
|
22
|
+
digits,
|
|
23
|
+
type InferOutput,
|
|
24
|
+
integer,
|
|
25
|
+
isoTimestamp,
|
|
26
|
+
literal,
|
|
27
|
+
number,
|
|
28
|
+
object,
|
|
29
|
+
picklist,
|
|
30
|
+
pipe,
|
|
31
|
+
record,
|
|
32
|
+
regex,
|
|
33
|
+
safeParse,
|
|
34
|
+
string,
|
|
35
|
+
summarize,
|
|
36
|
+
title,
|
|
37
|
+
transform,
|
|
38
|
+
union,
|
|
39
|
+
} from "valibot";
|
|
40
|
+
|
|
41
|
+
export default function (pi: ExtensionAPI) {
|
|
42
|
+
const commandRoot = "session";
|
|
43
|
+
|
|
44
|
+
let sessionManagerConfigurator: SessionManagerConfigurator;
|
|
45
|
+
|
|
46
|
+
pi.on("session_start", async (event, ctx) => {
|
|
47
|
+
sessionManagerConfigurator = new SessionManagerConfigurator();
|
|
48
|
+
const eventIsNotReloadOrStartUp =
|
|
49
|
+
event.reason !== "reload" && event.reason !== "startup";
|
|
50
|
+
if (eventIsNotReloadOrStartUp) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const sessionData = consumePersistedSessionSeriesData();
|
|
55
|
+
|
|
56
|
+
if (sessionData) {
|
|
57
|
+
pi.setSessionName(sessionData.sessionName);
|
|
58
|
+
const { customType, ...data } = sessionData.entry;
|
|
59
|
+
pi.appendEntry(customType, data);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const dayLimitResult =
|
|
63
|
+
sessionManagerConfigurator.getSessionDeletionDayLimit();
|
|
64
|
+
|
|
65
|
+
if (dayLimitResult instanceof SessionConfigError) {
|
|
66
|
+
ctx.ui.notify(
|
|
67
|
+
`Generated initial session manager config.
|
|
68
|
+
Since there's no default daylimit for getting rid of the sessions
|
|
69
|
+
${dayLimitResult.message}`,
|
|
70
|
+
"error",
|
|
71
|
+
);
|
|
72
|
+
sessionManagerConfigurator.generateInitialConfig(ctx.cwd);
|
|
73
|
+
return ctx.ui.notify(
|
|
74
|
+
`Every ${sessionManagerConfigurator.defaultSessionDeletionDayLimit} days unmodified sessions will be deleted `,
|
|
75
|
+
"warning",
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const sessions = await SessionManager.list(ctx.cwd);
|
|
80
|
+
const sessionFilter = new SessionFilter(
|
|
81
|
+
sessions,
|
|
82
|
+
new TimestampCalculator(),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const unmodifiedSessionsFromThePastNthDays =
|
|
86
|
+
sessionFilter.getModifiedSessionsBasedOnDayLimit(dayLimitResult);
|
|
87
|
+
|
|
88
|
+
if (unmodifiedSessionsFromThePastNthDays.length === 0) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
removeSessionFiles(unmodifiedSessionsFromThePastNthDays);
|
|
93
|
+
ctx.ui.notify(
|
|
94
|
+
`Removed ${unmodifiedSessionsFromThePastNthDays.length} inactive session(s).`,
|
|
95
|
+
"info",
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
pi.registerCommand(`${commandRoot}:clean:inactive`, {
|
|
100
|
+
handler: async (_, ctx) => {
|
|
101
|
+
const sessions = await SessionManager.list(ctx.cwd);
|
|
102
|
+
const sessionFilter = new SessionFilter(
|
|
103
|
+
sessions,
|
|
104
|
+
new TimestampCalculator(),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
handleSessionCleanInactive(
|
|
108
|
+
{
|
|
109
|
+
sessionFilter,
|
|
110
|
+
sessionManagerConfigurator,
|
|
111
|
+
removeSessionFiles,
|
|
112
|
+
},
|
|
113
|
+
ctx,
|
|
114
|
+
);
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
pi.registerCommand(`${commandRoot}:clean:older-than`, {
|
|
119
|
+
handler: async (args, ctx) => {
|
|
120
|
+
const result = safeParse(durationRecordSchema, args);
|
|
121
|
+
|
|
122
|
+
if (!result.success) {
|
|
123
|
+
return ctx.ui.notify(summarize(result.issues), "error");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const sessions = await SessionManager.list(ctx.cwd);
|
|
127
|
+
const sessionFilter = new SessionFilter(
|
|
128
|
+
sessions,
|
|
129
|
+
new TimestampCalculator(),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
handleSessionCleanOlderThan(
|
|
133
|
+
result.output,
|
|
134
|
+
{
|
|
135
|
+
sessionFilter,
|
|
136
|
+
removeSessionFiles,
|
|
137
|
+
},
|
|
138
|
+
ctx,
|
|
139
|
+
);
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
pi.registerCommand(`${commandRoot}:delete-last`, {
|
|
144
|
+
getArgumentCompletions: (prefix) => {
|
|
145
|
+
const autoCompleteItems: Array<AutocompleteItem> = [];
|
|
146
|
+
|
|
147
|
+
for (let i = 1; i <= 10; i++) {
|
|
148
|
+
autoCompleteItems.push({
|
|
149
|
+
value: i.toString(),
|
|
150
|
+
label: `last ${i.toString()}`,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return autoCompleteItems.filter((item) => item.value === prefix);
|
|
155
|
+
},
|
|
156
|
+
handler: async (args, ctx) => {
|
|
157
|
+
const intSchema = pipe(string(), digits(), transform(Number.parseInt));
|
|
158
|
+
const result = safeParse(intSchema, args);
|
|
159
|
+
|
|
160
|
+
if (!result.success) {
|
|
161
|
+
return ctx.ui.notify(summarize(result.issues), "error");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const sessions = await SessionManager.list(ctx.cwd);
|
|
165
|
+
const sessionFilter = new SessionFilter(
|
|
166
|
+
sessions,
|
|
167
|
+
new TimestampCalculator(),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
handleSessionDeleteLast(
|
|
171
|
+
result.output,
|
|
172
|
+
{
|
|
173
|
+
sessionFilter,
|
|
174
|
+
removeSessionFiles,
|
|
175
|
+
},
|
|
176
|
+
ctx,
|
|
177
|
+
);
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
pi.registerCommand(`${commandRoot}:series`, {
|
|
182
|
+
getArgumentCompletions: (prefix) => {
|
|
183
|
+
return sessionSeriesCommandsSchema.options
|
|
184
|
+
.filter((option) => option.startsWith(prefix))
|
|
185
|
+
.map((option) => ({
|
|
186
|
+
value: option,
|
|
187
|
+
label: option,
|
|
188
|
+
description: `Is it this ${option}`,
|
|
189
|
+
}));
|
|
190
|
+
},
|
|
191
|
+
handler: async (args, ctx) => {
|
|
192
|
+
const result = safeParse(sessionSeriesCommandsSchema, args);
|
|
193
|
+
|
|
194
|
+
if (!result.success) {
|
|
195
|
+
return ctx.ui.notify(summarize(result.issues), "error");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const sessions = await SessionManager.list(ctx.cwd);
|
|
199
|
+
|
|
200
|
+
await handleSessionSeries(
|
|
201
|
+
result.output,
|
|
202
|
+
{
|
|
203
|
+
sessionManagerConfigurator: new SessionManagerConfigurator(),
|
|
204
|
+
sessionFilter: new SessionFilter(sessions, new TimestampCalculator()),
|
|
205
|
+
getSessionEntryWithSeries,
|
|
206
|
+
removeSessionFiles,
|
|
207
|
+
},
|
|
208
|
+
ctx,
|
|
209
|
+
);
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export abstract class $TimestampCalculator {
|
|
215
|
+
readonly now = Date.now();
|
|
216
|
+
readonly HOUR_IN_MS = 60 ** 2 * 1000;
|
|
217
|
+
readonly DAY_IN_MS = 24 * this.HOUR_IN_MS;
|
|
218
|
+
readonly WEEK_IN_MS = 7 * this.DAY_IN_MS;
|
|
219
|
+
|
|
220
|
+
abstract hour(number: number): number;
|
|
221
|
+
abstract day(number: number): number;
|
|
222
|
+
abstract week(number: number): number;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
class TimestampCalculator extends $TimestampCalculator {
|
|
226
|
+
hour(number: number) {
|
|
227
|
+
return this.now - number * this.HOUR_IN_MS;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
day(number: number) {
|
|
231
|
+
return this.now - number * this.DAY_IN_MS;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
week(number: number) {
|
|
235
|
+
return this.now - number * this.WEEK_IN_MS;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export interface $SessionFilter {
|
|
240
|
+
readonly sessions: Array<SessionInfo>;
|
|
241
|
+
getModifiedSessionsBasedOnDurationIntegerAndUnit(
|
|
242
|
+
integer: DurationRecord["integer"],
|
|
243
|
+
durationUnit: DurationRecord["unit"],
|
|
244
|
+
): Array<SessionInfo>;
|
|
245
|
+
getModifiedSessionsBasedOnDayLimit(dayLimit: number): Array<SessionInfo>;
|
|
246
|
+
getSessionsThatAreTheLastNth(number: number): Array<SessionInfo>;
|
|
247
|
+
getSessionsThatHaveTheTitleAsAPrefix(title: string): Array<SessionInfo>;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
class SessionFilter implements $SessionFilter {
|
|
251
|
+
readonly sessions: Array<SessionInfo>;
|
|
252
|
+
|
|
253
|
+
readonly #timestampCalculator: $TimestampCalculator;
|
|
254
|
+
|
|
255
|
+
constructor(
|
|
256
|
+
sessions: Array<SessionInfo>,
|
|
257
|
+
timestampCalculator: $TimestampCalculator,
|
|
258
|
+
) {
|
|
259
|
+
this.sessions = sessions;
|
|
260
|
+
this.#timestampCalculator = timestampCalculator;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
getModifiedSessionsBasedOnDurationIntegerAndUnit(
|
|
264
|
+
integer: DurationRecord["integer"],
|
|
265
|
+
durationUnit: DurationRecord["unit"],
|
|
266
|
+
) {
|
|
267
|
+
return this.sessions.filter((session) => {
|
|
268
|
+
switch (durationUnit) {
|
|
269
|
+
case "hours":
|
|
270
|
+
case "h":
|
|
271
|
+
return (
|
|
272
|
+
session.modified.getTime() < this.#timestampCalculator.hour(integer)
|
|
273
|
+
);
|
|
274
|
+
case "days":
|
|
275
|
+
case "d":
|
|
276
|
+
return (
|
|
277
|
+
session.modified.getTime() < this.#timestampCalculator.day(integer)
|
|
278
|
+
);
|
|
279
|
+
case "weeks":
|
|
280
|
+
case "w":
|
|
281
|
+
return (
|
|
282
|
+
session.modified.getTime() < this.#timestampCalculator.week(integer)
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
getModifiedSessionsBasedOnDayLimit(dayLimit: number) {
|
|
289
|
+
return this.sessions.filter(
|
|
290
|
+
(session) =>
|
|
291
|
+
session.modified.getTime() < this.#timestampCalculator.day(dayLimit),
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
getSessionsThatAreTheLastNth(number: number) {
|
|
296
|
+
return this.sessions.slice(-number);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
getSessionsThatHaveTheTitleAsAPrefix(title: string) {
|
|
300
|
+
return this.sessions.filter((session) =>
|
|
301
|
+
session.name?.startsWith(`${title}${SESION_TITLE_SEPARATOR}`),
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
type SessionSeriesCommand = InferOutput<typeof sessionSeriesCommandsSchema>;
|
|
307
|
+
|
|
308
|
+
function removeSessionFiles(sessions: Array<SessionInfo>) {
|
|
309
|
+
for (const session of sessions) {
|
|
310
|
+
rmSync(session.path);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export type RemoveSessionFiles = typeof removeSessionFiles;
|
|
315
|
+
export const sessionManagerConfigSchema = object({
|
|
316
|
+
sessionDeletionDayLimit: pipe(number(), integer()),
|
|
317
|
+
seriesRecord: record(
|
|
318
|
+
pipe(string("must be a valid folder path"), title("folder-path")),
|
|
319
|
+
record(
|
|
320
|
+
pipe(string("must be a series Name"), title("series-name")),
|
|
321
|
+
pipe(
|
|
322
|
+
array(string("Must be a title")),
|
|
323
|
+
title("titles"),
|
|
324
|
+
checkItems(
|
|
325
|
+
(item, index, array) => array.indexOf(item) === index,
|
|
326
|
+
"Duplicate items are not allowed.",
|
|
327
|
+
),
|
|
328
|
+
),
|
|
329
|
+
),
|
|
330
|
+
),
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
export type SessionManagerConfig = InferOutput<
|
|
334
|
+
typeof sessionManagerConfigSchema
|
|
335
|
+
>;
|
|
336
|
+
|
|
337
|
+
export interface $SessionManagerConfigurator {
|
|
338
|
+
configureSessionDeletionDayLimit(days: number): void;
|
|
339
|
+
getSessionSeriesForCwd(cwd: string): string[] | SessionConfigError;
|
|
340
|
+
getSessionTitlesForSeriesBasedOnCwd(
|
|
341
|
+
cwd: string,
|
|
342
|
+
series: string,
|
|
343
|
+
): string[] | SessionConfigError;
|
|
344
|
+
appendSessionSeriesBasedOnCwd(
|
|
345
|
+
cwd: string,
|
|
346
|
+
series: string,
|
|
347
|
+
title: string,
|
|
348
|
+
): void;
|
|
349
|
+
deleteSessionSeriesBasedOnCwd(cwd: string, series: string): void;
|
|
350
|
+
getSessionDeletionDayLimit(): number | SessionConfigError;
|
|
351
|
+
generateInitialConfig(cwd: string): void;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export class SessionConfigError extends Error {
|
|
355
|
+
constructor(message: string) {
|
|
356
|
+
super(message);
|
|
357
|
+
this.name = "SessionConfigError";
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
class SessionManagerConfigurator implements $SessionManagerConfigurator {
|
|
362
|
+
#agentDir = getAgentDir();
|
|
363
|
+
|
|
364
|
+
#configName = "pi-session-manager.config.json";
|
|
365
|
+
|
|
366
|
+
readonly defaultSessionDeletionDayLimit = 3;
|
|
367
|
+
|
|
368
|
+
readonly #configPath = join(this.#agentDir, this.#configName);
|
|
369
|
+
|
|
370
|
+
generateInitialConfig(cwd: string): void {
|
|
371
|
+
const config = {
|
|
372
|
+
sessionDeletionDayLimit: this.defaultSessionDeletionDayLimit,
|
|
373
|
+
seriesRecord: {
|
|
374
|
+
[cwd]: {},
|
|
375
|
+
},
|
|
376
|
+
} satisfies SessionManagerConfig;
|
|
377
|
+
|
|
378
|
+
writeFileSync(this.#configPath, JSON.stringify(config), {
|
|
379
|
+
encoding: "utf-8",
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
#readConfig() {
|
|
384
|
+
try {
|
|
385
|
+
const config = readFileSync(this.#configPath, {
|
|
386
|
+
encoding: "utf-8",
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const parsed = JSON.parse(config);
|
|
390
|
+
|
|
391
|
+
return safeParse(sessionManagerConfigSchema, parsed);
|
|
392
|
+
} catch (e) {
|
|
393
|
+
if (e instanceof Error) {
|
|
394
|
+
return new SessionConfigError(e.message);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return new SessionConfigError(e as string);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
deleteSessionSeriesBasedOnCwd(cwd: string, series: string): void {
|
|
402
|
+
const result = this.#readConfig();
|
|
403
|
+
|
|
404
|
+
if (result instanceof SessionConfigError) {
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (!result.success) {
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const { seriesRecord } = result.output;
|
|
413
|
+
const cwdSeriesRecord = seriesRecord[cwd];
|
|
414
|
+
|
|
415
|
+
if (!cwdSeriesRecord) {
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
delete cwdSeriesRecord[series.trim()];
|
|
420
|
+
|
|
421
|
+
writeFileSync(
|
|
422
|
+
this.#configPath,
|
|
423
|
+
JSON.stringify({ ...result.output, seriesRecord }),
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
getSessionDeletionDayLimit() {
|
|
428
|
+
const result = this.#readConfig();
|
|
429
|
+
|
|
430
|
+
if (result instanceof SessionConfigError) {
|
|
431
|
+
return result;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (!result.success) {
|
|
435
|
+
return new SessionConfigError(summarize(result.issues));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return result.output.sessionDeletionDayLimit;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
getSessionSeriesForCwd(cwd: string) {
|
|
442
|
+
const result = this.#readConfig();
|
|
443
|
+
|
|
444
|
+
if (result instanceof SessionConfigError) {
|
|
445
|
+
return result;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (!result.success) {
|
|
449
|
+
return new SessionConfigError(summarize(result.issues));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return Object.keys(result.output.seriesRecord[cwd] ?? {});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
getSessionTitlesForSeriesBasedOnCwd(cwd: string, series: string) {
|
|
456
|
+
const result = this.#readConfig();
|
|
457
|
+
|
|
458
|
+
if (result instanceof SessionConfigError) {
|
|
459
|
+
return result;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (!result.success) {
|
|
463
|
+
return new SessionConfigError(summarize(result.issues));
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return result.output.seriesRecord[cwd]?.[series.trim()] ?? [];
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
configureSessionDeletionDayLimit(days: number): void {
|
|
470
|
+
const result = this.#readConfig();
|
|
471
|
+
|
|
472
|
+
if (result instanceof SessionConfigError) {
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (!result.success) {
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
result.output.sessionDeletionDayLimit = days;
|
|
481
|
+
|
|
482
|
+
writeFileSync(this.#configPath, JSON.stringify(result.output), {
|
|
483
|
+
encoding: "utf-8",
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
appendSessionSeriesBasedOnCwd(
|
|
488
|
+
cwd: string,
|
|
489
|
+
series: string,
|
|
490
|
+
title: string,
|
|
491
|
+
): void {
|
|
492
|
+
const result = this.#readConfig();
|
|
493
|
+
|
|
494
|
+
if (result instanceof SessionConfigError) {
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (!result.success) {
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const normalizedSeries = series.trim();
|
|
503
|
+
const normalizedTitle = title.trim();
|
|
504
|
+
const cwdSeriesRecord = result.output.seriesRecord[cwd] ?? {};
|
|
505
|
+
const titles = cwdSeriesRecord[normalizedSeries] ?? [];
|
|
506
|
+
|
|
507
|
+
if (
|
|
508
|
+
!titles.some((existingTitle) => existingTitle.trim() === normalizedTitle)
|
|
509
|
+
) {
|
|
510
|
+
cwdSeriesRecord[normalizedSeries] = titles.concat(normalizedTitle);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
result.output.seriesRecord[cwd] = cwdSeriesRecord;
|
|
514
|
+
|
|
515
|
+
writeFileSync(this.#configPath, JSON.stringify(result.output), {
|
|
516
|
+
encoding: "utf-8",
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function getSessionEntryWithSeries(
|
|
522
|
+
sesssionEntries: SessionEntry[],
|
|
523
|
+
): SessionSeriesEntry | undefined {
|
|
524
|
+
return sesssionEntries.find(
|
|
525
|
+
(entry): entry is SessionSeriesEntry =>
|
|
526
|
+
entry.type === sessionSeriesEntrySchema.entries.type.literal &&
|
|
527
|
+
entry.customType === sessionSeriesEntrySchema.entries.customType.literal,
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export type GetSessionEntryWithSeries = typeof getSessionEntryWithSeries;
|
|
532
|
+
|
|
533
|
+
export function handleSessionCleanInactive(
|
|
534
|
+
deps: {
|
|
535
|
+
sessionFilter: $SessionFilter;
|
|
536
|
+
sessionManagerConfigurator: $SessionManagerConfigurator;
|
|
537
|
+
removeSessionFiles: RemoveSessionFiles;
|
|
538
|
+
},
|
|
539
|
+
ctx: ExtensionCommandContext,
|
|
540
|
+
) {
|
|
541
|
+
ctx.ui.notify(
|
|
542
|
+
"Getting rid of all sessions that have been inactive for three days",
|
|
543
|
+
"warning",
|
|
544
|
+
);
|
|
545
|
+
const dayLimit = deps.sessionManagerConfigurator.getSessionDeletionDayLimit();
|
|
546
|
+
|
|
547
|
+
if (dayLimit instanceof SessionConfigError) {
|
|
548
|
+
ctx.ui.notify(dayLimit.message, "error");
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
deps.removeSessionFiles(
|
|
553
|
+
deps.sessionFilter.getModifiedSessionsBasedOnDayLimit(dayLimit),
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const integerWithUnitRE = /(?<integer>\d+)(?<unit>days|weeks|hours)/;
|
|
558
|
+
const integerWithUnitShortRE = /(?<integer>\d+)(?<unit>d|w|h)/;
|
|
559
|
+
const durationRecordSchema = union(
|
|
560
|
+
[
|
|
561
|
+
pipe(
|
|
562
|
+
string(),
|
|
563
|
+
regex(integerWithUnitRE),
|
|
564
|
+
transform((input) => {
|
|
565
|
+
const { integer, unit } = integerWithUnitRE.exec(input)?.groups as {
|
|
566
|
+
integer: string;
|
|
567
|
+
unit: "days" | "weeks" | "hours";
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
return { integer: Number.parseInt(integer), unit };
|
|
571
|
+
}),
|
|
572
|
+
),
|
|
573
|
+
pipe(
|
|
574
|
+
string(),
|
|
575
|
+
regex(integerWithUnitShortRE),
|
|
576
|
+
transform((input) => {
|
|
577
|
+
const { integer, unit } = integerWithUnitShortRE.exec(input)
|
|
578
|
+
?.groups as {
|
|
579
|
+
integer: string;
|
|
580
|
+
unit: "d" | "w" | "h";
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
return { integer: Number.parseInt(integer), unit };
|
|
584
|
+
}),
|
|
585
|
+
),
|
|
586
|
+
],
|
|
587
|
+
"You can only use an integer suffixed by days, weeks, or hours or the shorthand d/w/h units",
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
export type DurationRecord = InferOutput<typeof durationRecordSchema>;
|
|
591
|
+
|
|
592
|
+
export function handleSessionCleanOlderThan(
|
|
593
|
+
input: DurationRecord,
|
|
594
|
+
deps: {
|
|
595
|
+
sessionFilter: $SessionFilter;
|
|
596
|
+
removeSessionFiles: RemoveSessionFiles;
|
|
597
|
+
},
|
|
598
|
+
ctx: ExtensionCommandContext,
|
|
599
|
+
) {
|
|
600
|
+
ctx.ui.notify(
|
|
601
|
+
`Deleteing sessions that are from ${input.integer} ${input.unit} ago`,
|
|
602
|
+
);
|
|
603
|
+
deps.removeSessionFiles(
|
|
604
|
+
deps.sessionFilter.getModifiedSessionsBasedOnDurationIntegerAndUnit(
|
|
605
|
+
input.integer,
|
|
606
|
+
input.unit,
|
|
607
|
+
),
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export function handleSessionDeleteLast(
|
|
612
|
+
number: number,
|
|
613
|
+
deps: {
|
|
614
|
+
sessionFilter: $SessionFilter;
|
|
615
|
+
removeSessionFiles: RemoveSessionFiles;
|
|
616
|
+
},
|
|
617
|
+
ctx: ExtensionCommandContext,
|
|
618
|
+
) {
|
|
619
|
+
ctx.ui.notify(`Deleting the last ${number}`);
|
|
620
|
+
deps.removeSessionFiles(
|
|
621
|
+
deps.sessionFilter.getSessionsThatAreTheLastNth(number),
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export const SESION_TITLE_SEPARATOR = "--";
|
|
626
|
+
export const sessionSeriesCommandsSchema = picklist([
|
|
627
|
+
"create",
|
|
628
|
+
"delete",
|
|
629
|
+
"new",
|
|
630
|
+
"continue",
|
|
631
|
+
]);
|
|
632
|
+
export const sessionSeriesEntrySchema = object({
|
|
633
|
+
type: literal("custom"),
|
|
634
|
+
customType: literal("session-manager/series"),
|
|
635
|
+
data: object({
|
|
636
|
+
series: string(),
|
|
637
|
+
createdAt: pipe(string(), isoTimestamp()),
|
|
638
|
+
}),
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
export type SessionSeriesEntry = Extract<SessionEntry, { type: "custom" }> &
|
|
642
|
+
InferOutput<typeof sessionSeriesEntrySchema>;
|
|
643
|
+
|
|
644
|
+
export const sessionSeriesDataSchema = object({
|
|
645
|
+
sessionName: string(),
|
|
646
|
+
entry: object({
|
|
647
|
+
customType: literal("session-manager/series"),
|
|
648
|
+
series: string(),
|
|
649
|
+
createdAt: pipe(string(), isoTimestamp()),
|
|
650
|
+
}),
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
export type SessionSeriesData = InferOutput<typeof sessionSeriesDataSchema>;
|
|
654
|
+
|
|
655
|
+
const sessionSeriesDataTempFileName = "pi-session-manager.session-data.json";
|
|
656
|
+
|
|
657
|
+
export function getSessionSeriesDataTempPath(baseDir = tmpdir()) {
|
|
658
|
+
return join(baseDir, sessionSeriesDataTempFileName);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
export function persistSessionSeriesData(
|
|
662
|
+
sessionData: SessionSeriesData,
|
|
663
|
+
tempPath = getSessionSeriesDataTempPath(),
|
|
664
|
+
) {
|
|
665
|
+
writeFileSync(tempPath, JSON.stringify(sessionData), {
|
|
666
|
+
encoding: "utf-8",
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
export function consumePersistedSessionSeriesData(
|
|
671
|
+
tempPath = getSessionSeriesDataTempPath(),
|
|
672
|
+
) {
|
|
673
|
+
if (!existsSync(tempPath)) {
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
try {
|
|
678
|
+
const parsed = JSON.parse(
|
|
679
|
+
readFileSync(tempPath, {
|
|
680
|
+
encoding: "utf-8",
|
|
681
|
+
}),
|
|
682
|
+
);
|
|
683
|
+
|
|
684
|
+
const result = safeParse(sessionSeriesDataSchema, parsed);
|
|
685
|
+
|
|
686
|
+
if (!result.success) {
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
unlinkSync(tempPath);
|
|
691
|
+
return result.output;
|
|
692
|
+
} catch {
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function promptForUniqueTrimmedInput(
|
|
698
|
+
ctx: ExtensionCommandContext,
|
|
699
|
+
prompt: string,
|
|
700
|
+
description: string | undefined,
|
|
701
|
+
existingValues: string[],
|
|
702
|
+
duplicateMessage: (value: string) => string,
|
|
703
|
+
) {
|
|
704
|
+
return (async () => {
|
|
705
|
+
while (true) {
|
|
706
|
+
const value = await ctx.ui.input(prompt, description);
|
|
707
|
+
const trimmedValue = value?.trim();
|
|
708
|
+
|
|
709
|
+
if (!trimmedValue) {
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (
|
|
714
|
+
existingValues.some(
|
|
715
|
+
(existingValue) => existingValue.trim() === trimmedValue,
|
|
716
|
+
)
|
|
717
|
+
) {
|
|
718
|
+
ctx.ui.notify(duplicateMessage(trimmedValue), "warning");
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
return trimmedValue;
|
|
723
|
+
}
|
|
724
|
+
})();
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function getSessionTitlesForSeriesFromSessions(
|
|
728
|
+
sessions: SessionInfo[],
|
|
729
|
+
series: string,
|
|
730
|
+
) {
|
|
731
|
+
const prefix = `${series.trim()}${SESION_TITLE_SEPARATOR}`;
|
|
732
|
+
|
|
733
|
+
return sessions
|
|
734
|
+
.map((session) => session.name?.trim())
|
|
735
|
+
.filter((name): name is string => Boolean(name?.startsWith(prefix)))
|
|
736
|
+
.map((name) => name.slice(prefix.length).trim())
|
|
737
|
+
.filter((title) => title.length > 0);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
export async function handleSessionSeries(
|
|
741
|
+
command: SessionSeriesCommand,
|
|
742
|
+
deps: {
|
|
743
|
+
sessionManagerConfigurator: $SessionManagerConfigurator;
|
|
744
|
+
sessionFilter: $SessionFilter;
|
|
745
|
+
getSessionEntryWithSeries: GetSessionEntryWithSeries;
|
|
746
|
+
removeSessionFiles: RemoveSessionFiles;
|
|
747
|
+
},
|
|
748
|
+
ctx: ExtensionCommandContext,
|
|
749
|
+
) {
|
|
750
|
+
switch (command) {
|
|
751
|
+
case "create": {
|
|
752
|
+
const seriesResult =
|
|
753
|
+
deps.sessionManagerConfigurator.getSessionSeriesForCwd(ctx.cwd);
|
|
754
|
+
|
|
755
|
+
if (seriesResult instanceof SessionConfigError) {
|
|
756
|
+
ctx.ui.notify(seriesResult.message, "error");
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const series = await promptForUniqueTrimmedInput(
|
|
761
|
+
ctx,
|
|
762
|
+
"What is the name of your session series?",
|
|
763
|
+
"What are you focused on?",
|
|
764
|
+
seriesResult,
|
|
765
|
+
(value) => `This series has already been added ${value}`,
|
|
766
|
+
);
|
|
767
|
+
|
|
768
|
+
const titlesResult =
|
|
769
|
+
deps.sessionManagerConfigurator.getSessionTitlesForSeriesBasedOnCwd(
|
|
770
|
+
ctx.cwd,
|
|
771
|
+
series,
|
|
772
|
+
);
|
|
773
|
+
|
|
774
|
+
if (titlesResult instanceof SessionConfigError) {
|
|
775
|
+
ctx.ui.notify(titlesResult.message, "error");
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const title = await promptForUniqueTrimmedInput(
|
|
780
|
+
ctx,
|
|
781
|
+
"What is the name of the new session you want to make in this one?",
|
|
782
|
+
"What task is a part of what you are focusing on?",
|
|
783
|
+
titlesResult,
|
|
784
|
+
(value) => `This title has already been added ${value}`,
|
|
785
|
+
);
|
|
786
|
+
|
|
787
|
+
const sessionData = {
|
|
788
|
+
sessionName: `${series}${SESION_TITLE_SEPARATOR}${title}`,
|
|
789
|
+
entry: {
|
|
790
|
+
customType: sessionSeriesEntrySchema.entries.customType.literal,
|
|
791
|
+
series,
|
|
792
|
+
createdAt: new Date().toISOString(),
|
|
793
|
+
},
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
persistSessionSeriesData(sessionData);
|
|
797
|
+
|
|
798
|
+
await ctx.newSession({
|
|
799
|
+
withSession: async (sessionCtx) => {
|
|
800
|
+
deps.sessionManagerConfigurator.appendSessionSeriesBasedOnCwd(
|
|
801
|
+
sessionCtx.cwd,
|
|
802
|
+
series,
|
|
803
|
+
title,
|
|
804
|
+
);
|
|
805
|
+
|
|
806
|
+
sessionCtx.ui.notify("Your session series has been created");
|
|
807
|
+
},
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
break;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
case "delete": {
|
|
814
|
+
const result = deps.sessionManagerConfigurator.getSessionSeriesForCwd(
|
|
815
|
+
ctx.cwd,
|
|
816
|
+
);
|
|
817
|
+
|
|
818
|
+
if (result instanceof SessionConfigError) {
|
|
819
|
+
ctx.ui.notify(result.message, "error");
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const series = await ctx.ui.select(
|
|
824
|
+
"Which session series would you like to delete?",
|
|
825
|
+
result,
|
|
826
|
+
);
|
|
827
|
+
|
|
828
|
+
if (!series) {
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
deps.removeSessionFiles(
|
|
833
|
+
deps.sessionFilter.getSessionsThatHaveTheTitleAsAPrefix(series.trim()),
|
|
834
|
+
);
|
|
835
|
+
deps.sessionManagerConfigurator.deleteSessionSeriesBasedOnCwd(
|
|
836
|
+
ctx.cwd,
|
|
837
|
+
series,
|
|
838
|
+
);
|
|
839
|
+
ctx.ui.notify(`This series ${series} and it's related sessions`);
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
case "new": {
|
|
844
|
+
const result = deps.sessionManagerConfigurator.getSessionSeriesForCwd(
|
|
845
|
+
ctx.cwd,
|
|
846
|
+
);
|
|
847
|
+
|
|
848
|
+
if (result instanceof SessionConfigError) {
|
|
849
|
+
ctx.ui.notify(result.message, "error");
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const series = await ctx.ui.select(
|
|
854
|
+
"Which session series would you like to create a new session in?",
|
|
855
|
+
result,
|
|
856
|
+
);
|
|
857
|
+
|
|
858
|
+
if (!series) {
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const titleResult =
|
|
863
|
+
deps.sessionManagerConfigurator.getSessionTitlesForSeriesBasedOnCwd(
|
|
864
|
+
ctx.cwd,
|
|
865
|
+
series,
|
|
866
|
+
);
|
|
867
|
+
|
|
868
|
+
if (titleResult instanceof SessionConfigError) {
|
|
869
|
+
ctx.ui.notify(titleResult.message, "error");
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const title = await promptForUniqueTrimmedInput(
|
|
874
|
+
ctx,
|
|
875
|
+
"What is the name of the this new session?",
|
|
876
|
+
"What do you want your agent to do now?",
|
|
877
|
+
titleResult,
|
|
878
|
+
(value) => `This title has already been added ${value}`,
|
|
879
|
+
);
|
|
880
|
+
|
|
881
|
+
const sessionData = {
|
|
882
|
+
sessionName: `${series}${SESION_TITLE_SEPARATOR}${title}`,
|
|
883
|
+
entry: {
|
|
884
|
+
customType: sessionSeriesEntrySchema.entries.customType.literal,
|
|
885
|
+
series,
|
|
886
|
+
createdAt: new Date().toISOString(),
|
|
887
|
+
},
|
|
888
|
+
};
|
|
889
|
+
|
|
890
|
+
persistSessionSeriesData(sessionData);
|
|
891
|
+
|
|
892
|
+
await ctx.newSession({
|
|
893
|
+
withSession: async (sessionCtx) => {
|
|
894
|
+
deps.sessionManagerConfigurator.appendSessionSeriesBasedOnCwd(
|
|
895
|
+
sessionCtx.cwd,
|
|
896
|
+
series,
|
|
897
|
+
title,
|
|
898
|
+
);
|
|
899
|
+
|
|
900
|
+
sessionCtx.ui.notify(`You have created a new session in ${series}
|
|
901
|
+
with ${title}
|
|
902
|
+
`);
|
|
903
|
+
},
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
break;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
case "continue": {
|
|
910
|
+
const entries = ctx.sessionManager.getEntries();
|
|
911
|
+
const entry = deps.getSessionEntryWithSeries(entries);
|
|
912
|
+
|
|
913
|
+
if (!entry) {
|
|
914
|
+
ctx.ui.notify("No session series was found", "error");
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const titlesResult = getSessionTitlesForSeriesFromSessions(
|
|
919
|
+
deps.sessionFilter.getSessionsThatHaveTheTitleAsAPrefix(
|
|
920
|
+
entry.data.series,
|
|
921
|
+
),
|
|
922
|
+
entry.data.series,
|
|
923
|
+
);
|
|
924
|
+
|
|
925
|
+
const title = await promptForUniqueTrimmedInput(
|
|
926
|
+
ctx,
|
|
927
|
+
`What's the new title for the session in series ${entry.data.series}`,
|
|
928
|
+
undefined,
|
|
929
|
+
titlesResult,
|
|
930
|
+
(value) => `This title has already been added ${value}`,
|
|
931
|
+
);
|
|
932
|
+
|
|
933
|
+
const sessionData = {
|
|
934
|
+
sessionName: `${entry.data.series}${SESION_TITLE_SEPARATOR}${title}`,
|
|
935
|
+
entry: {
|
|
936
|
+
customType: sessionSeriesEntrySchema.entries.customType.literal,
|
|
937
|
+
series: entry.data.series,
|
|
938
|
+
createdAt: new Date().toISOString(),
|
|
939
|
+
},
|
|
940
|
+
};
|
|
941
|
+
|
|
942
|
+
persistSessionSeriesData(sessionData);
|
|
943
|
+
|
|
944
|
+
await ctx.newSession({
|
|
945
|
+
withSession: async (sessionCtx) => {
|
|
946
|
+
deps.sessionManagerConfigurator.appendSessionSeriesBasedOnCwd(
|
|
947
|
+
sessionCtx.cwd,
|
|
948
|
+
entry.data.series,
|
|
949
|
+
title,
|
|
950
|
+
);
|
|
951
|
+
|
|
952
|
+
sessionCtx.ui.notify(`You have created a new session in ${entry.data.series}
|
|
953
|
+
with ${title}
|
|
954
|
+
`);
|
|
955
|
+
},
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
break;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
default: {
|
|
962
|
+
const exhaustiveCheck: never = command;
|
|
963
|
+
return exhaustiveCheck;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
}
|