@lebronj/pi-suite 0.1.7 → 0.1.9

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 CHANGED
@@ -15,7 +15,7 @@ pi install npm:pi-subagents
15
15
  Or use the bootstrap script to install Pi, configure the team OpenAI-compatible endpoint, install this suite, and set up Bun + qmd for memory search:
16
16
 
17
17
  ```bash
18
- curl -fsSL https://registry.npmjs.org/@lebronj/pi-suite/-/pi-suite-0.1.7.tgz | tar -xzO package/scripts/bootstrap.sh | bash
18
+ curl -fsSL https://registry.npmjs.org/@lebronj/pi-suite/-/pi-suite-0.1.8.tgz | tar -xzO package/scripts/bootstrap.sh | bash
19
19
  ```
20
20
 
21
21
  ## What Is Included
@@ -67,7 +67,7 @@ qmd collection add ~/.pi/agent/memory --name pi-memory
67
67
  qmd embed
68
68
  ```
69
69
 
70
- Memory versioning is enabled by default. It snapshots `~/.pi/agent/memory` and `~/.pi/agent/skill-drafts` into `~/.pi/agent/evolution`, commits local changes automatically, and leaves push manual by default.
70
+ Memory versioning is enabled by default. It snapshots `~/.pi/agent/memory` and `~/.pi/agent/skill-drafts` into `~/.pi/agent/evolution`, commits local changes automatically, and leaves push manual by default. `memory_curate` also scans yesterday's daily log into `REVIEW.md` when learning is enabled and the daily file changed since the last scan.
71
71
 
72
72
  Useful commands:
73
73
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lebronj/pi-suite",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "JHP's Pi extension suite for team coding workflows",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -46,7 +46,7 @@ Memory tools:
46
46
  - `memory_edit`: read/add/replace/remove/replace_all/compact structured entries in `MEMORY.md`, `USER.md`, `STATE.md`, and `REVIEW.md`.
47
47
  - `scratchpad`: add/done/undo/clear/list checklist items.
48
48
  - `memory_search`: qmd-backed keyword, semantic, or deep search across memory files.
49
- - `memory_curate`: manually run curator lifecycle rules.
49
+ - `memory_curate`: manually run curator lifecycle rules and scan yesterday's daily log into `REVIEW.md` when learning is enabled.
50
50
  - `memory_learning_approve`: approve a proposed memory promotion or disabled skill draft by exact id.
51
51
  - `memory_learning_reject`: reject or archive a review candidate/proposal without deleting it.
52
52
  - `memory_skill_drafts`: list proposed skill drafts.
@@ -74,6 +74,7 @@ Curator and learning behavior:
74
74
  - Quotas reset when `month` or `reset` rolls over.
75
75
  - Mutations are audited to `audit/curator.jsonl`.
76
76
  - Session shutdown may extract conservative learning candidates into `REVIEW.md`; they are not injected as normal memory and are not auto-enabled.
77
+ - `memory_curate` scans yesterday's daily log once per content hash into review candidates, then curator lifecycle and proposal rules process those candidates.
77
78
  - Repeated candidates can become proposed memory promotions or proposed disabled skill drafts after `memory_curate`.
78
79
  - Approval is explicit by default: memory proposals write to memory stores; skill proposals write disabled drafts under `~/.pi/agent/skill-drafts/`.
79
80
  - The curator avoids semantic auto-delete/merge; ambiguous learning stays in review first.
@@ -84,6 +85,7 @@ Memory versioning:
84
85
  - Versioning mirror and snapshots live at `~/.pi/agent/evolution` by default.
85
86
  - No remote is configured by default; memory evolution stays local per user/machine unless the user adds a personal private remote.
86
87
  - Automatic local snapshot + commit is enabled by default; automatic push is disabled unless `PI_EVOLUTION_AUTO_PUSH=1`.
88
+ - Snapshots use a sliding window: keep the latest 100 by default, and delete the oldest snapshot directory and manifest when a new snapshot exceeds the limit.
87
89
  - Snapshots run before mutating memory tools, curator runs, learning approve/reject, session summaries/handoffs, compact handoffs, restore, and external curator `run-once`.
88
90
  - Slash commands: `/memory-version-status`, `/memory-version-snapshot [reason]`, `/memory-version-list`, `/memory-version-restore <snapshot-id> [memory|skill-drafts|all]`, `/memory-version-push`.
89
91
  - Restore always writes a pre-restore snapshot first, then restores selected files and commits the restored state.
@@ -125,6 +127,7 @@ Useful memory environment variables:
125
127
  - `PI_EVOLUTION_BRANCH`: override branch; default `main`.
126
128
  - `PI_EVOLUTION_AUTO_COMMIT=0`: disable automatic local commits.
127
129
  - `PI_EVOLUTION_AUTO_PUSH=1`: push automatically after commits.
130
+ - `PI_EVOLUTION_MAX_SNAPSHOTS`: maximum local snapshots to keep; default `100`.
128
131
 
129
132
  ## Web And Research
130
133
 
@@ -196,7 +196,7 @@ Pi-memory mirrors the authoritative runtime directories into a local evolution r
196
196
 
197
197
  Authoritative runtime data remains `~/.pi/agent/memory` and `~/.pi/agent/skill-drafts`; `~/.pi/agent/evolution` is a versioned mirror and backup repo.
198
198
 
199
- Automatic hooks snapshot before and sync/commit after `memory_write`, mutating `memory_edit`, mutating `scratchpad`, `memory_curate`, learning approve/reject, session summary/handoff writes, compaction handoffs, and external `jhp-pi-memory-curator run-once`. Read-only operations do not snapshot.
199
+ Automatic hooks snapshot before and sync/commit after `memory_write`, mutating `memory_edit`, mutating `scratchpad`, `memory_curate`, learning approve/reject, session summary/handoff writes, compaction handoffs, and external `jhp-pi-memory-curator run-once`. `memory_curate` also scans yesterday's daily log into `REVIEW.md` when learning is enabled and that daily file changed since the last scan. Read-only operations do not snapshot. Snapshots use a sliding window and keep the latest 100 snapshots by default; creating snapshot 101 deletes the oldest snapshot directory and manifest.
200
200
 
201
201
  Tools and slash commands:
202
202
 
@@ -242,7 +242,7 @@ The controller uses a systemd user timer when available and falls back to cron.
242
242
  | `PI_MEMORY_QMD_UPDATE` | `background`, `manual`, `off` | `background` | Control qmd update after writes |
243
243
  | `PI_MEMORY_NO_SEARCH` | `1` | unset | Disable per-turn search injection |
244
244
  | `PI_MEMORY_SUMMARIZE_TRANSITIONS` | `1`, `true`, `yes`, `on` | unset | Also summarize lifecycle transitions |
245
- | `PI_MEMORY_LEARNING` | `off`, `review`, `auto-review` | `review` | Control session learning candidate extraction |
245
+ | `PI_MEMORY_LEARNING` | `off`, `review`, `auto-review` | `review` | Control session and curator daily learning candidate extraction |
246
246
  | `PI_MEMORY_LEARNING_MIN_CONFIDENCE` | `low`, `medium`, `high` | `medium` | Minimum extractor confidence to keep |
247
247
  | `PI_MEMORY_SKILL_DRAFTS` | `off`, `review` | `review` | Allow curator to propose disabled skill drafts |
248
248
  | `PI_MEMORY_AUTO_APPROVE_MEMORY` | `1`, `true`, `yes`, `on` | unset | YOLO mode for approving newly created memory proposals |
@@ -253,6 +253,7 @@ The controller uses a systemd user timer when available and falls back to cron.
253
253
  | `PI_EVOLUTION_BRANCH` | branch | `main` | Local branch used for init/clone |
254
254
  | `PI_EVOLUTION_AUTO_COMMIT` | `0`, `1`, `true`, `false` | `1` | Commit sync/snapshot changes automatically |
255
255
  | `PI_EVOLUTION_AUTO_PUSH` | `0`, `1`, `true`, `false` | `0` | Push after commits automatically |
256
+ | `PI_EVOLUTION_MAX_SNAPSHOTS` | positive integer | `100` | Maximum snapshots to keep in the local sliding window |
256
257
 
257
258
  ## Development
258
259
 
@@ -26,6 +26,7 @@
26
26
  */
27
27
 
28
28
  import { type ExecFileOptions, execFile } from "node:child_process";
29
+ import { createHash } from "node:crypto";
29
30
  import * as fs from "node:fs";
30
31
  import * as path from "node:path";
31
32
  import { complete, type Message, StringEnum } from "@earendil-works/pi-ai";
@@ -89,6 +90,7 @@ let USER_FILE = path.join(MEMORY_DIR, "USER.md");
89
90
  let STATE_FILE = path.join(MEMORY_DIR, "STATE.md");
90
91
  let REVIEW_FILE = path.join(MEMORY_DIR, "REVIEW.md");
91
92
  let SCRATCHPAD_FILE = path.join(MEMORY_DIR, "SCRATCHPAD.md");
93
+ let LEARNING_STATE_FILE = path.join(MEMORY_DIR, ".learning-state.json");
92
94
  let DAILY_DIR = path.join(MEMORY_DIR, "daily");
93
95
  let SKILL_DRAFTS_DIR = path.join(path.dirname(MEMORY_DIR), "skill-drafts");
94
96
 
@@ -104,6 +106,7 @@ export function _setBaseDir(baseDir: string) {
104
106
  STATE_FILE = path.join(baseDir, "STATE.md");
105
107
  REVIEW_FILE = path.join(baseDir, "REVIEW.md");
106
108
  SCRATCHPAD_FILE = path.join(baseDir, "SCRATCHPAD.md");
109
+ LEARNING_STATE_FILE = path.join(baseDir, ".learning-state.json");
107
110
  DAILY_DIR = path.join(baseDir, "daily");
108
111
  SKILL_DRAFTS_DIR = path.join(path.dirname(baseDir), "skill-drafts");
109
112
  }
@@ -562,9 +565,9 @@ function shouldKeepLearningCandidate(confidence: "low" | "medium" | "high", env:
562
565
  return confidenceRank(confidence) >= confidenceRank(getMemoryLearningMinConfidence(env));
563
566
  }
564
567
 
565
- function buildLearningExtractorPrompt(conversationText: string, truncated: boolean, totalChars: number): string {
568
+ function buildLearningExtractorPrompt(conversationText: string, truncated: boolean, totalChars: number, sourceLabel = "session transcript"): string {
566
569
  const lines = [
567
- "Extract zero or more review candidates from this session transcript.",
570
+ `Extract zero or more review candidates from this ${sourceLabel}.`,
568
571
  "Return JSON exactly shaped as: {\"candidates\":[{\"kind\":\"bug_fix|skill_candidate|preference|project_fact\",\"confidence\":\"low|medium|high\",\"signature\":\"short stable signature\",\"summary\":\"optional concise summary\",\"targetHints\":[\"memory\",\"skill\"],\"evidence\":\"optional compact evidence\"}]}",
569
572
  "Only include verified bug fixes when a failure was followed by an edit/action and successful validation.",
570
573
  "Drop one-off trivia, transient status, workflow artifacts, and loop artifacts.",
@@ -671,31 +674,90 @@ export function parseLearningExtractorResponse(raw: string): ReviewCandidateInpu
671
674
  return candidates;
672
675
  }
673
676
 
674
- async function runSessionLearningExtractor(ctx: ExtensionContext): Promise<number> {
675
- if (getMemoryLearningMode() === "off") return 0;
676
- const branch = getSessionBranch(ctx);
677
- if (!branch || !ctx.model) return 0;
677
+ type DailyLearningState = {
678
+ daily?: Record<string, { hash: string; scannedAt: string; candidates: number }>;
679
+ };
680
+
681
+ function readLearningState(): DailyLearningState {
682
+ try {
683
+ const parsed = JSON.parse(fs.readFileSync(LEARNING_STATE_FILE, "utf-8"));
684
+ return parsed && typeof parsed === "object" ? parsed as DailyLearningState : {};
685
+ } catch {
686
+ return {};
687
+ }
688
+ }
689
+
690
+ function writeLearningState(state: DailyLearningState): void {
691
+ fs.writeFileSync(LEARNING_STATE_FILE, `${JSON.stringify(state, null, 2)}\n`, "utf-8");
692
+ }
693
+
694
+ function contentHash(content: string): string {
695
+ return createHash("sha256").update(content).digest("hex");
696
+ }
697
+
698
+ async function extractLearningCandidates(
699
+ ctx: ExtensionContext,
700
+ text: string,
701
+ sourceLabel: string,
702
+ source: string,
703
+ date?: string,
704
+ ): Promise<ReviewCandidateInput[]> {
705
+ if (getMemoryLearningMode() === "off" || !ctx.model) return [];
678
706
  const apiKey = await resolveExitSummaryApiKey(ctx);
679
- if (!apiKey) return 0;
680
- const conversation = serializeSessionConversation(branch);
681
- if (!conversation.hasMessages || !conversation.text.trim()) return 0;
682
- const truncated = truncateText(conversation.text.trim(), LEARNING_EXTRACTOR_MAX_CHARS, "end");
707
+ if (!apiKey) return [];
708
+ const trimmed = text.trim();
709
+ if (!trimmed) return [];
710
+ const truncated = truncateText(trimmed, LEARNING_EXTRACTOR_MAX_CHARS, "end");
683
711
  const messages: Message[] = [{
684
712
  role: "user",
685
- content: [{ type: "text", text: buildLearningExtractorPrompt(truncated.text, truncated.truncated, conversation.text.trim().length) }],
713
+ content: [{ type: "text", text: buildLearningExtractorPrompt(truncated.text, truncated.truncated, trimmed.length, sourceLabel) }],
686
714
  timestamp: Date.now(),
687
715
  }];
716
+ const response = await complete(ctx.model, { systemPrompt: LEARNING_EXTRACTOR_SYSTEM_PROMPT, messages }, { apiKey, reasoningEffort: "low" });
717
+ const raw = response.content.filter((part): part is { type: "text"; text: string } => part.type === "text").map((part) => part.text).join("\n");
718
+ return parseLearningExtractorResponse(raw).map((candidate) => ({ ...candidate, source, date }));
719
+ }
720
+
721
+ async function writeLearningCandidates(candidates: ReviewCandidateInput[]): Promise<number> {
722
+ let written = 0;
723
+ const store = new FileMemoryStore(MEMORY_DIR);
724
+ for (const candidate of candidates) {
725
+ const result = await upsertReviewCandidate(store, candidate);
726
+ if (result.changed) written += 1;
727
+ }
728
+ return written;
729
+ }
730
+
731
+ type DailyLearningScanResult = { scanned: boolean; changed: number; skipped?: string };
732
+
733
+ async function runYesterdayDailyLearningScan(ctx: ExtensionContext): Promise<DailyLearningScanResult> {
734
+ if (getMemoryLearningMode() === "off") return { scanned: false, changed: 0, skipped: "learning off" };
735
+ const date = yesterdayStr();
736
+ const dailyContent = readFileSafe(dailyPath(date));
737
+ if (!dailyContent?.trim()) return { scanned: false, changed: 0, skipped: `daily/${date}.md empty or missing` };
738
+ const hash = contentHash(dailyContent);
739
+ const state = readLearningState();
740
+ const previous = state.daily?.[date];
741
+ if (previous?.hash === hash) return { scanned: false, changed: 0, skipped: `daily/${date}.md already scanned` };
688
742
  try {
689
- const response = await complete(ctx.model, { systemPrompt: LEARNING_EXTRACTOR_SYSTEM_PROMPT, messages }, { apiKey, reasoningEffort: "low" });
690
- const raw = response.content.filter((part): part is { type: "text"; text: string } => part.type === "text").map((part) => part.text).join("\n");
691
- const candidates = parseLearningExtractorResponse(raw);
692
- let written = 0;
693
- const store = new FileMemoryStore(MEMORY_DIR);
694
- for (const candidate of candidates) {
695
- const result = await upsertReviewCandidate(store, candidate);
696
- if (result.changed) written += 1;
697
- }
698
- return written;
743
+ const candidates = await extractLearningCandidates(ctx, dailyContent, `daily log for ${date}`, `daily/${date}`, date);
744
+ const changed = await writeLearningCandidates(candidates);
745
+ state.daily = { ...(state.daily || {}), [date]: { hash, scannedAt: new Date().toISOString(), candidates: candidates.length } };
746
+ writeLearningState(state);
747
+ return { scanned: true, changed };
748
+ } catch {
749
+ return { scanned: false, changed: 0, skipped: `daily/${date}.md scan failed` };
750
+ }
751
+ }
752
+
753
+ async function runSessionLearningExtractor(ctx: ExtensionContext): Promise<number> {
754
+ const branch = getSessionBranch(ctx);
755
+ if (!branch) return 0;
756
+ const conversation = serializeSessionConversation(branch);
757
+ if (!conversation.hasMessages || !conversation.text.trim()) return 0;
758
+ try {
759
+ const candidates = await extractLearningCandidates(ctx, conversation.text, "session transcript", "session_shutdown");
760
+ return writeLearningCandidates(candidates);
699
761
  } catch {
700
762
  return 0;
701
763
  }
@@ -1439,7 +1501,7 @@ function getSnapshotMode(): "stable" | "per-turn" {
1439
1501
  }
1440
1502
 
1441
1503
 
1442
- async function runCurator(reason: string): Promise<string> {
1504
+ async function runCurator(reason: string, ctx?: ExtensionContext): Promise<string> {
1443
1505
  ensureDirs();
1444
1506
  await evolutionBeforeChange(`curator before ${reason}`, "memory: snapshot before curate", "tool");
1445
1507
  const store = new FileMemoryStore(MEMORY_DIR);
@@ -1448,6 +1510,7 @@ async function runCurator(reason: string): Promise<string> {
1448
1510
  auditLog: new JsonlAuditLog(MEMORY_DIR),
1449
1511
  reason,
1450
1512
  });
1513
+ const dailyLearningResult = ctx ? await runYesterdayDailyLearningScan(ctx) : { scanned: false, changed: 0 };
1451
1514
  const lifecycleResult = await applyReviewLifecycle(store);
1452
1515
  const memoryResult = await proposeMemoryPromotions(store);
1453
1516
  const skillResult = getMemorySkillDraftsMode() === "off" ? { created: 0, proposals: [] } : await proposeSkillDrafts(store, { draftsDir: SKILL_DRAFTS_DIR });
@@ -1465,7 +1528,7 @@ async function runCurator(reason: string): Promise<string> {
1465
1528
  autoApprovedSkills += 1;
1466
1529
  }
1467
1530
  }
1468
- const learningChanges = lifecycleResult.changed + memoryResult.created + skillResult.created + autoApprovedMemory + autoApprovedSkills;
1531
+ const learningChanges = dailyLearningResult.changed + lifecycleResult.changed + memoryResult.created + skillResult.created + autoApprovedMemory + autoApprovedSkills;
1469
1532
  if (result.patches.length > 0 || learningChanges > 0) {
1470
1533
  snapshotDirty = true;
1471
1534
  await ensureQmdAvailableForUpdate();
@@ -1473,6 +1536,7 @@ async function runCurator(reason: string): Promise<string> {
1473
1536
  }
1474
1537
  await evolutionAfterChange("memory: sync after curate");
1475
1538
  const notes = [
1539
+ dailyLearningResult.scanned ? `scanned yesterday daily, wrote ${dailyLearningResult.changed} review candidate change(s)` : dailyLearningResult.skipped ? `daily learning skipped: ${dailyLearningResult.skipped}` : "",
1476
1540
  memoryResult.created > 0 ? `proposed ${memoryResult.created} memory promotion(s)` : "",
1477
1541
  skillResult.created > 0 ? `proposed ${skillResult.created} skill draft(s)` : "",
1478
1542
  autoApprovedMemory > 0 ? `auto-approved ${autoApprovedMemory} memory promotion(s)` : "",
@@ -1509,6 +1573,7 @@ function formatEvolutionStatusText(status: ReturnType<typeof getEvolutionGitStat
1509
1573
  `dirty: ${status.dirty}`,
1510
1574
  `autoCommit: ${status.autoCommit}`,
1511
1575
  `autoPush: ${status.autoPush}`,
1576
+ `snapshotLimit: ${status.maxSnapshots}`,
1512
1577
  `lastCommit: ${status.lastCommit || "n/a"}`,
1513
1578
  status.status ? `status:\n${status.status}` : "status: clean",
1514
1579
  ].join("\n");
@@ -2270,11 +2335,11 @@ export default function (pi: ExtensionAPI) {
2270
2335
  pi.registerTool({
2271
2336
  name: "memory_curate",
2272
2337
  label: "Memory Curate",
2273
- description: "Run the time-aware memory curator now. It deduplicates exact entries, updates event/quota lifecycle metadata, and appends stale temporary memories to REVIEW.md.",
2338
+ description: "Run the time-aware memory curator now. It scans yesterday's daily log into REVIEW.md, deduplicates exact entries, updates event/quota lifecycle metadata, and appends stale temporary memories to REVIEW.md.",
2274
2339
  parameters: Type.Object({}),
2275
2340
  async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
2276
2341
  try {
2277
- const summary = await runCurator("memory_curate tool");
2342
+ const summary = await runCurator("memory_curate tool", _ctx);
2278
2343
  return { content: [{ type: "text", text: summary }], details: { summary } };
2279
2344
  } catch (error) {
2280
2345
  const message = error instanceof Error ? error.message : String(error);
@@ -4,6 +4,7 @@ export interface EvolutionConfig {
4
4
  enabled: boolean;
5
5
  autoCommit: boolean;
6
6
  autoPush: boolean;
7
+ maxSnapshots: number;
7
8
  repoDir: string;
8
9
  remote: string | null;
9
10
  branch: string;
@@ -19,6 +20,7 @@ type EvolutionEnv = Partial<
19
20
  | "PI_EVOLUTION_ENABLED"
20
21
  | "PI_EVOLUTION_AUTO_COMMIT"
21
22
  | "PI_EVOLUTION_AUTO_PUSH"
23
+ | "PI_EVOLUTION_MAX_SNAPSHOTS"
22
24
  | "HOME"
23
25
  | "USERPROFILE"
24
26
  | "HOMEDRIVE"
@@ -30,6 +32,7 @@ type EvolutionEnv = Partial<
30
32
  export const DEFAULT_EVOLUTION_REMOTE = "";
31
33
  export const LEGACY_SHARED_EVOLUTION_REMOTE = "https://github.com/LRM-Teams/pi-evolution.git";
32
34
  export const DEFAULT_EVOLUTION_BRANCH = "main";
35
+ export const DEFAULT_EVOLUTION_MAX_SNAPSHOTS = 100;
33
36
 
34
37
  function homeDir(env: EvolutionEnv): string {
35
38
  return env.HOME ?? env.USERPROFILE ?? (env.HOMEDRIVE && env.HOMEPATH ? `${env.HOMEDRIVE}${env.HOMEPATH}` : undefined) ?? "~";
@@ -43,6 +46,12 @@ function truthy(value: string | undefined, fallback: boolean): boolean {
43
46
  return fallback;
44
47
  }
45
48
 
49
+ function positiveInteger(value: string | undefined, fallback: number): number {
50
+ if (value === undefined) return fallback;
51
+ const parsed = Number.parseInt(value.trim(), 10);
52
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
53
+ }
54
+
46
55
  function expandHome(input: string, env: EvolutionEnv): string {
47
56
  if (input === "~") return homeDir(env);
48
57
  if (input.startsWith("~/")) return path.join(homeDir(env), input.slice(2));
@@ -55,6 +64,7 @@ export function resolveEvolutionConfig(memoryDir: string, env: EvolutionEnv = pr
55
64
  enabled: truthy(env.PI_EVOLUTION_ENABLED, true),
56
65
  autoCommit: truthy(env.PI_EVOLUTION_AUTO_COMMIT, true),
57
66
  autoPush: truthy(env.PI_EVOLUTION_AUTO_PUSH, false),
67
+ maxSnapshots: positiveInteger(env.PI_EVOLUTION_MAX_SNAPSHOTS, DEFAULT_EVOLUTION_MAX_SNAPSHOTS),
58
68
  repoDir: path.resolve(expandHome(env.PI_EVOLUTION_DIR || path.join(agentDir, "evolution"), env)),
59
69
  remote: env.PI_EVOLUTION_REMOTE?.trim() || null,
60
70
  branch: env.PI_EVOLUTION_BRANCH || DEFAULT_EVOLUTION_BRANCH,
@@ -14,6 +14,7 @@ export interface GitStatus {
14
14
  lastCommit: string | null;
15
15
  autoPush: boolean;
16
16
  autoCommit: boolean;
17
+ maxSnapshots: number;
17
18
  enabled: boolean;
18
19
  }
19
20
 
@@ -104,6 +105,7 @@ export function getEvolutionGitStatus(config: EvolutionConfig): GitStatus {
104
105
  lastCommit: null,
105
106
  autoPush: config.autoPush,
106
107
  autoCommit: config.autoCommit,
108
+ maxSnapshots: config.maxSnapshots,
107
109
  enabled: config.enabled,
108
110
  };
109
111
  }
@@ -118,6 +120,7 @@ export function getEvolutionGitStatus(config: EvolutionConfig): GitStatus {
118
120
  lastCommit: runGit(config.repoDir, ["log", "-1", "--oneline"], { allowFailure: true }) || null,
119
121
  autoPush: config.autoPush,
120
122
  autoCommit: config.autoCommit,
123
+ maxSnapshots: config.maxSnapshots,
121
124
  enabled: config.enabled,
122
125
  };
123
126
  }
@@ -1,4 +1,4 @@
1
- export { DEFAULT_EVOLUTION_BRANCH, DEFAULT_EVOLUTION_REMOTE, LEGACY_SHARED_EVOLUTION_REMOTE, resolveEvolutionConfig, type EvolutionConfig } from "./config.ts";
1
+ export { DEFAULT_EVOLUTION_BRANCH, DEFAULT_EVOLUTION_MAX_SNAPSHOTS, DEFAULT_EVOLUTION_REMOTE, LEGACY_SHARED_EVOLUTION_REMOTE, resolveEvolutionConfig, type EvolutionConfig } from "./config.ts";
2
2
  export { commitEvolutionChanges, ensureEvolutionRepo, getEvolutionGitStatus, pushEvolution, type GitCommitResult, type GitStatus } from "./git.ts";
3
3
  export { buildManifest, createSnapshotId, listManifests, readManifest, writeManifest, type EvolutionManifest } from "./manifest.ts";
4
4
  export { restoreEvolutionSnapshot, type RestoreResult, type RestoreTarget } from "./restore.ts";
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { EvolutionConfig } from "./config.ts";
4
- import { countFiles } from "./file-utils.ts";
4
+ import { countFiles, pathExists } from "./file-utils.ts";
5
5
 
6
6
  export interface EvolutionManifest {
7
7
  id: string;
@@ -57,12 +57,27 @@ export function readManifest(config: EvolutionConfig, id: string): EvolutionMani
57
57
  }
58
58
 
59
59
  export function listManifests(config: EvolutionConfig, limit = 20): EvolutionManifest[] {
60
+ return readAllManifests(config).slice(0, limit);
61
+ }
62
+
63
+ export function pruneOldSnapshots(config: EvolutionConfig): EvolutionManifest[] {
64
+ const manifests = readAllManifests(config);
65
+ if (manifests.length <= config.maxSnapshots) return [];
66
+
67
+ const removed = manifests.slice(config.maxSnapshots);
68
+ for (const manifest of removed) {
69
+ fs.rmSync(path.join(config.repoDir, "snapshots", manifest.id), { recursive: true, force: true });
70
+ fs.rmSync(path.join(config.repoDir, "manifests", `${manifest.id}.json`), { force: true });
71
+ }
72
+ return removed;
73
+ }
74
+
75
+ function readAllManifests(config: EvolutionConfig): EvolutionManifest[] {
60
76
  const dir = path.join(config.repoDir, "manifests");
61
77
  if (!fs.existsSync(dir)) return [];
62
78
  return fs.readdirSync(dir)
63
79
  .filter((file) => file.endsWith(".json"))
64
- .sort()
65
- .reverse()
66
- .slice(0, limit)
67
- .map((file) => JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8")) as EvolutionManifest);
80
+ .map((file) => JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8")) as EvolutionManifest)
81
+ .filter((manifest) => pathExists(path.join(config.repoDir, "snapshots", manifest.id)))
82
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id));
68
83
  }
@@ -2,7 +2,7 @@ import * as path from "node:path";
2
2
  import type { EvolutionConfig } from "./config.ts";
3
3
  import { copyDirContents, emptyDir } from "./file-utils.ts";
4
4
  import { commitEvolutionChanges, ensureEvolutionRepo, type GitCommitResult } from "./git.ts";
5
- import { buildManifest, createSnapshotId, writeManifest, type EvolutionManifest } from "./manifest.ts";
5
+ import { buildManifest, createSnapshotId, pruneOldSnapshots, writeManifest, type EvolutionManifest } from "./manifest.ts";
6
6
  import { syncCurrentToEvolution } from "./sync.ts";
7
7
 
8
8
  export interface SnapshotOptions {
@@ -29,6 +29,7 @@ export function createEvolutionSnapshot(config: EvolutionConfig, options: Snapsh
29
29
  copyDirContents(config.skillDraftsDir, path.join(snapshotDir, "skill-drafts"));
30
30
  const manifest = buildManifest(config, id, options.reason, options.trigger || "manual", options.sessionId);
31
31
  writeManifest(config, manifest);
32
+ pruneOldSnapshots(config);
32
33
  syncCurrentToEvolution(config);
33
34
  const commit = commitEvolutionChanges(config, options.commitMessage || `memory: snapshot ${id}`);
34
35
  return { manifest, commit };
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { execFileSync } from "node:child_process";
3
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { test } from "node:test";
@@ -51,6 +51,24 @@ test("creates snapshot, manifest, current mirrors, and git commit", () => {
51
51
  assert.match(getEvolutionGitStatus(config).lastCommit || "", /memory: test snapshot/);
52
52
  });
53
53
 
54
+ test("prunes old snapshots beyond the configured window", () => {
55
+ const { memoryDir, repoDir, config } = testConfig();
56
+ config.maxSnapshots = 3;
57
+ mkdirSync(memoryDir, { recursive: true });
58
+
59
+ const ids: string[] = [];
60
+ for (let i = 0; i < 4; i += 1) {
61
+ writeFileSync(join(memoryDir, "MEMORY.md"), `version ${i}\n`, "utf-8");
62
+ const result = createEvolutionSnapshot(config, { reason: `snapshot ${i}`, trigger: "test", commitMessage: `memory: snapshot ${i}` });
63
+ ids.push(result.manifest?.id || "");
64
+ }
65
+
66
+ assert.equal(existsSync(join(repoDir, "snapshots", ids[0])), false);
67
+ assert.equal(existsSync(join(repoDir, "manifests", `${ids[0]}.json`)), false);
68
+ assert.equal(readdirSync(join(repoDir, "snapshots")).length, 3);
69
+ assert.deepEqual(listManifests(config, 10).map((manifest) => manifest.id), ids.slice(1).reverse());
70
+ });
71
+
54
72
  test("local-only config removes the legacy shared team remote", () => {
55
73
  const { config, repoDir } = testConfig();
56
74
  mkdirSync(repoDir, { recursive: true });