@phren/cli 0.0.8 → 0.0.10

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.
@@ -227,6 +227,167 @@ export async function handleConsolidateMemories(args = []) {
227
227
  return;
228
228
  console.log(`Updated backups (${backups.length}): ${backups.join(", ")}`);
229
229
  }
230
+ export async function handleGcMaintain(args = []) {
231
+ const dryRun = args.includes("--dry-run");
232
+ const phrenPath = getPhrenPath();
233
+ const { execSync } = await import("child_process");
234
+ const report = {
235
+ gitGcRan: false,
236
+ commitsSquashed: 0,
237
+ sessionsRemoved: 0,
238
+ runtimeLogsRemoved: 0,
239
+ };
240
+ // 1. Run git gc --aggressive on the ~/.phren repo
241
+ if (dryRun) {
242
+ console.log("[dry-run] Would run: git gc --aggressive");
243
+ }
244
+ else {
245
+ try {
246
+ execSync("git gc --aggressive --quiet", { cwd: phrenPath, stdio: "pipe" });
247
+ report.gitGcRan = true;
248
+ console.log("git gc --aggressive: done");
249
+ }
250
+ catch (err) {
251
+ console.error(`git gc failed: ${errorMessage(err)}`);
252
+ }
253
+ }
254
+ // 2. Squash old auto-save commits (older than 7 days) into weekly summaries
255
+ const sevenDaysAgo = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10);
256
+ let oldCommits = [];
257
+ try {
258
+ const raw = execSync(`git log --oneline --before="${sevenDaysAgo}" --format="%H %s"`, { cwd: phrenPath, encoding: "utf8" }).trim();
259
+ if (raw) {
260
+ oldCommits = raw.split("\n").filter((l) => l.includes("auto-save:") || l.includes("[auto]"));
261
+ }
262
+ }
263
+ catch {
264
+ // Not a git repo or no commits — skip silently
265
+ }
266
+ if (oldCommits.length === 0) {
267
+ console.log("Commit squash: no old auto-save commits to squash.");
268
+ }
269
+ else if (dryRun) {
270
+ console.log(`[dry-run] Would squash ${oldCommits.length} auto-save commits older than 7 days into weekly summaries.`);
271
+ report.commitsSquashed = oldCommits.length;
272
+ }
273
+ else {
274
+ // Group by ISO week (YYYY-Www) based on commit timestamp
275
+ const commitsByWeek = new Map();
276
+ for (const line of oldCommits) {
277
+ const hash = line.split(" ")[0];
278
+ try {
279
+ const dateStr = execSync(`git log -1 --format="%ci" ${hash}`, { cwd: phrenPath, encoding: "utf8" }).trim();
280
+ const date = new Date(dateStr);
281
+ const weekStart = new Date(date);
282
+ weekStart.setDate(date.getDate() - date.getDay()); // start of week (Sunday)
283
+ const weekKey = weekStart.toISOString().slice(0, 10);
284
+ if (!commitsByWeek.has(weekKey))
285
+ commitsByWeek.set(weekKey, []);
286
+ commitsByWeek.get(weekKey).push(hash);
287
+ }
288
+ catch {
289
+ // Skip commits we can't resolve
290
+ }
291
+ }
292
+ // For each week with multiple commits, soft-reset to oldest and amend into a summary
293
+ for (const [weekKey, hashes] of commitsByWeek.entries()) {
294
+ if (hashes.length < 2)
295
+ continue;
296
+ try {
297
+ const oldest = hashes[hashes.length - 1];
298
+ const newest = hashes[0];
299
+ // Use git rebase --onto to squash: squash all into the oldest parent
300
+ const parentOfOldest = execSync(`git rev-parse ${oldest}^`, { cwd: phrenPath, encoding: "utf8" }).trim();
301
+ // Build rebase script via env variable to squash all but first to "squash"
302
+ const rebaseScript = hashes
303
+ .map((h, i) => `${i === hashes.length - 1 ? "pick" : "squash"} ${h} auto-save`)
304
+ .reverse()
305
+ .join("\n");
306
+ const scriptPath = path.join(phrenPath, ".runtime", `gc-rebase-${weekKey}.sh`);
307
+ fs.writeFileSync(scriptPath, rebaseScript);
308
+ // Use GIT_SEQUENCE_EDITOR to feed our script
309
+ execSync(`GIT_SEQUENCE_EDITOR="cat ${scriptPath} >" git rebase -i ${parentOfOldest}`, { cwd: phrenPath, stdio: "pipe" });
310
+ fs.unlinkSync(scriptPath);
311
+ report.commitsSquashed += hashes.length - 1;
312
+ console.log(`Squashed ${hashes.length} auto-save commits for week of ${weekKey} (${newest.slice(0, 7)}..${oldest.slice(0, 7)}).`);
313
+ }
314
+ catch {
315
+ // Squashing is best-effort — log and continue
316
+ console.warn(` Could not squash auto-save commits for week ${weekKey} (possibly non-linear history). Skipping.`);
317
+ }
318
+ }
319
+ if (report.commitsSquashed === 0) {
320
+ console.log("Commit squash: all old auto-save weeks have only one commit, nothing to squash.");
321
+ }
322
+ }
323
+ // 3. Prune stale session markers from ~/.phren/.sessions/ older than 30 days
324
+ const sessionsDir = path.join(phrenPath, ".sessions");
325
+ const thirtyDaysAgo = Date.now() - 30 * 86400000;
326
+ if (fs.existsSync(sessionsDir)) {
327
+ const entries = fs.readdirSync(sessionsDir);
328
+ for (const entry of entries) {
329
+ const fullPath = path.join(sessionsDir, entry);
330
+ try {
331
+ const stat = fs.statSync(fullPath);
332
+ if (stat.mtimeMs < thirtyDaysAgo) {
333
+ if (dryRun) {
334
+ console.log(`[dry-run] Would remove session marker: .sessions/${entry}`);
335
+ }
336
+ else {
337
+ fs.unlinkSync(fullPath);
338
+ }
339
+ report.sessionsRemoved++;
340
+ }
341
+ }
342
+ catch {
343
+ // Skip unreadable entries
344
+ }
345
+ }
346
+ }
347
+ const sessionsVerb = dryRun ? "Would remove" : "Removed";
348
+ console.log(`${sessionsVerb} ${report.sessionsRemoved} stale session marker(s) from .sessions/`);
349
+ // 4. Trim runtime logs from ~/.phren/.runtime/ older than 30 days
350
+ const runtimeDir = path.join(phrenPath, ".runtime");
351
+ const logExtensions = new Set([".log", ".jsonl", ".json"]);
352
+ if (fs.existsSync(runtimeDir)) {
353
+ const entries = fs.readdirSync(runtimeDir);
354
+ for (const entry of entries) {
355
+ const ext = path.extname(entry);
356
+ if (!logExtensions.has(ext))
357
+ continue;
358
+ // Never trim the active audit log or telemetry config
359
+ if (entry === "audit.log" || entry === "telemetry.json")
360
+ continue;
361
+ const fullPath = path.join(runtimeDir, entry);
362
+ try {
363
+ const stat = fs.statSync(fullPath);
364
+ if (stat.mtimeMs < thirtyDaysAgo) {
365
+ if (dryRun) {
366
+ console.log(`[dry-run] Would remove runtime log: .runtime/${entry}`);
367
+ }
368
+ else {
369
+ fs.unlinkSync(fullPath);
370
+ }
371
+ report.runtimeLogsRemoved++;
372
+ }
373
+ }
374
+ catch {
375
+ // Skip unreadable entries
376
+ }
377
+ }
378
+ }
379
+ const logsVerb = dryRun ? "Would remove" : "Removed";
380
+ console.log(`${logsVerb} ${report.runtimeLogsRemoved} stale runtime log(s) from .runtime/`);
381
+ // 5. Summary
382
+ if (!dryRun) {
383
+ appendAuditLog(phrenPath, "maintain_gc", `gitGc=${report.gitGcRan} squashed=${report.commitsSquashed} sessions=${report.sessionsRemoved} logs=${report.runtimeLogsRemoved}`);
384
+ }
385
+ console.log(`\nGC complete:${dryRun ? " (dry-run)" : ""}` +
386
+ ` git_gc=${report.gitGcRan}` +
387
+ ` commits_squashed=${report.commitsSquashed}` +
388
+ ` sessions_pruned=${report.sessionsRemoved}` +
389
+ ` logs_pruned=${report.runtimeLogsRemoved}`);
390
+ }
230
391
  // ── Maintain router ──────────────────────────────────────────────────────────
231
392
  export async function handleMaintain(args) {
232
393
  const sub = args[0];
@@ -245,6 +406,8 @@ export async function handleMaintain(args) {
245
406
  return handleExtractMemories(rest[0]);
246
407
  case "restore":
247
408
  return handleRestoreBackup(rest);
409
+ case "gc":
410
+ return handleGcMaintain(rest);
248
411
  default:
249
412
  console.log(`phren maintain - memory maintenance and governance
250
413
 
@@ -258,7 +421,9 @@ Subcommands:
258
421
  Deduplicate FINDINGS.md bullets. Run after a burst of work
259
422
  when findings feel repetitive, or monthly to keep things clean.
260
423
  phren maintain extract [project] Mine git/GitHub signals into memory candidates
261
- phren maintain restore [project] List and restore from .bak files`);
424
+ phren maintain restore [project] List and restore from .bak files
425
+ phren maintain gc [--dry-run] Garbage-collect the ~/.phren repo: git gc, squash old
426
+ auto-save commits, prune stale session markers and runtime logs`);
262
427
  if (sub) {
263
428
  console.error(`\nUnknown maintain subcommand: "${sub}"`);
264
429
  process.exit(1);
@@ -345,7 +345,7 @@ export async function handleHookPrompt() {
345
345
  parts.push(`Findings ready for consolidation:`);
346
346
  parts.push(notices.join("\n"));
347
347
  parts.push(`Run phren-consolidate when ready.`);
348
- parts.push(`</phren-notice>`);
348
+ parts.push(`<phren-notice>`);
349
349
  }
350
350
  if (noticeFile) {
351
351
  try {
@@ -367,7 +367,7 @@ export async function handleHookPrompt() {
367
367
  }
368
368
  catch (err) {
369
369
  const msg = errorMessage(err);
370
- process.stdout.write(`\n<phren-error>phren hook failed: ${msg}. Check ~/.phren/.runtime/debug.log for details.</phren-error>\n`);
370
+ process.stdout.write(`\n<phren-error>phren hook failed: ${msg}. Check ~/.phren/.runtime/debug.log for details.<phren-error>\n`);
371
371
  debugLog(`hook-prompt error: ${msg}`);
372
372
  process.exit(0);
373
373
  }