@lebronj/pi-suite 0.1.3 → 0.1.5

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.
@@ -12,6 +12,7 @@ Structured, time-aware memory extension for pi. It stores memory as plain Markdo
12
12
  - Curator core for exact dedupe, event lifecycle updates, temporary review, quota reset, and audit logs.
13
13
  - Review-first learning candidates that can become memory promotions or disabled skill drafts after approval.
14
14
  - Optional external curator service via systemd user timer or cron so curation can run even when pi is closed.
15
+ - Snapshot + git versioning for `memory/` and disabled `skill-drafts/`, with restore support.
15
16
 
16
17
  ## Installation
17
18
 
@@ -49,6 +50,11 @@ The extension auto-creates the `pi-memory` qmd collection and path contexts on s
49
50
  daily/YYYY-MM-DD.md # Daily append-only logs
50
51
  ~/.pi/agent/skill-drafts/
51
52
  <slug>/SKILL.md # Disabled skill drafts created after approval
53
+ ~/.pi/agent/evolution/
54
+ memory/ # Current memory mirror
55
+ skill-drafts/ # Current skill draft mirror
56
+ snapshots/<id>/ # Point-in-time backup with manifest.json
57
+ manifests/<id>.json # Snapshot manifest index
52
58
  ```
53
59
 
54
60
  Structured entries are separated by `§` and may start with metadata:
@@ -85,6 +91,11 @@ Metadata keys currently supported by tools and curator rules:
85
91
  | `memory_curator_enable` | Enable the external daily curator service |
86
92
  | `memory_curator_disable` | Disable the external daily curator service |
87
93
  | `memory_curator_status` | Show service backend, schedule, and state |
94
+ | `memory_version_status` | Show local evolution git repo status |
95
+ | `memory_version_snapshot` | Manually snapshot memory and skill drafts |
96
+ | `memory_version_list` | List recent snapshots |
97
+ | `memory_version_restore` | Restore `memory`, `skill-drafts`, or `all` from a snapshot id |
98
+ | `memory_version_push` | Manually push the evolution repo to GitHub |
88
99
 
89
100
  ### memory_write Targets
90
101
 
@@ -168,6 +179,37 @@ Old candidates are lifecycle-managed without deletion. Low-confidence candidates
168
179
 
169
180
  Current learning extraction is text-based: it reads user/assistant conversation messages and asks the active model for structured candidates. It does not yet inspect structured tool-call graphs directly. Curator patch audit remains in `audit/curator.jsonl`; learning approvals are tracked through `REVIEW.md` proposal metadata and status changes.
170
181
 
182
+ ## Memory Versioning
183
+
184
+ Pi-memory mirrors the authoritative runtime directories into a local evolution repo and stores point-in-time snapshots before important changes:
185
+
186
+ ```text
187
+ ~/.pi/agent/evolution/
188
+ memory/
189
+ skill-drafts/
190
+ snapshots/<snapshot-id>/
191
+ memory/
192
+ skill-drafts/
193
+ manifest.json
194
+ manifests/<snapshot-id>.json
195
+ ```
196
+
197
+ Authoritative runtime data remains `~/.pi/agent/memory` and `~/.pi/agent/skill-drafts`; `~/.pi/agent/evolution` is a versioned mirror and backup repo.
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.
200
+
201
+ Tools and slash commands:
202
+
203
+ - `memory_version_status` / `/memory-version-status`
204
+ - `memory_version_snapshot` / `/memory-version-snapshot [reason]`
205
+ - `memory_version_list` / `/memory-version-list`
206
+ - `memory_version_restore` / `/memory-version-restore <snapshot-id> [memory|skill-drafts|all]`
207
+ - `memory_version_push` / `/memory-version-push`
208
+
209
+ Restore always creates a pre-restore snapshot first, then restores the selected target, syncs the mirror, and commits `memory: restore snapshot <id>`.
210
+
211
+ Default remote is `https://github.com/LRM-Teams/pi-evolution.git`. Auto commit is on; auto push is off unless `PI_EVOLUTION_AUTO_PUSH=1` is set. The repo should be private because memory contents are committed in plaintext, including any secret accidentally written to memory.
212
+
171
213
  ## External Curator Service
172
214
 
173
215
  `@jhp/pi-memory` includes a CLI and pi tools for an external daily service. The service is independent of the pi process, so curation can still run when pi is closed.
@@ -178,6 +220,8 @@ CLI:
178
220
  jhp-pi-memory-curator enable --schedule 03:00
179
221
  jhp-pi-memory-curator status
180
222
  jhp-pi-memory-curator run-once
223
+ jhp-pi-memory-curator snapshot --reason "manual backup"
224
+ jhp-pi-memory-curator push
181
225
  jhp-pi-memory-curator disable
182
226
  ```
183
227
 
@@ -203,6 +247,12 @@ The controller uses a systemd user timer when available and falls back to cron.
203
247
  | `PI_MEMORY_SKILL_DRAFTS` | `off`, `review` | `review` | Allow curator to propose disabled skill drafts |
204
248
  | `PI_MEMORY_AUTO_APPROVE_MEMORY` | `1`, `true`, `yes`, `on` | unset | YOLO mode for approving newly created memory proposals |
205
249
  | `PI_MEMORY_AUTO_APPROVE_SKILL_DRAFTS` | `1`, `true`, `yes`, `on` | unset | YOLO mode for creating newly proposed disabled skill drafts |
250
+ | `PI_EVOLUTION_ENABLED` | `0`, `1`, `true`, `false` | `1` | Enable snapshot + git versioning |
251
+ | `PI_EVOLUTION_DIR` | path | `~/.pi/agent/evolution` | Local evolution repo directory |
252
+ | `PI_EVOLUTION_REMOTE` | URL | `https://github.com/LRM-Teams/pi-evolution.git` | Git remote for manual/optional push |
253
+ | `PI_EVOLUTION_BRANCH` | branch | `main` | Local branch used for init/clone |
254
+ | `PI_EVOLUTION_AUTO_COMMIT` | `0`, `1`, `true`, `false` | `1` | Commit sync/snapshot changes automatically |
255
+ | `PI_EVOLUTION_AUTO_PUSH` | `0`, `1`, `true`, `false` | `0` | Push after commits automatically |
206
256
 
207
257
  ## Development
208
258
 
@@ -50,6 +50,16 @@ import {
50
50
  import { applyReviewLifecycle, approveMemoryPromotion, proposeMemoryPromotions, rejectReviewItem } from "./src/learning/memory.ts";
51
51
  import { approveSkillDraft, listSkillDraftProposals, proposeSkillDrafts } from "./src/learning/skills.ts";
52
52
  import { FileMemoryStore } from "./src/curator-store/file-store.ts";
53
+ import {
54
+ createEvolutionSnapshot,
55
+ getEvolutionGitStatus,
56
+ listManifests,
57
+ pushEvolution,
58
+ resolveEvolutionConfig,
59
+ restoreEvolutionSnapshot,
60
+ syncEvolutionAfterChange,
61
+ type RestoreTarget,
62
+ } from "./src/evolution/index.ts";
53
63
  import { disableCuratorService, enableCuratorService, getCuratorServiceStatus } from "./src/service-controller.ts";
54
64
 
55
65
 
@@ -82,6 +92,10 @@ let SCRATCHPAD_FILE = path.join(MEMORY_DIR, "SCRATCHPAD.md");
82
92
  let DAILY_DIR = path.join(MEMORY_DIR, "daily");
83
93
  let SKILL_DRAFTS_DIR = path.join(path.dirname(MEMORY_DIR), "skill-drafts");
84
94
 
95
+ function currentEvolutionConfig() {
96
+ return resolveEvolutionConfig(MEMORY_DIR);
97
+ }
98
+
85
99
  /** Override base directory (for testing). */
86
100
  export function _setBaseDir(baseDir: string) {
87
101
  MEMORY_DIR = baseDir;
@@ -313,12 +327,14 @@ const STRUCTURED_MEMORY_TARGETS = ["memory", "user", "state", "review"] as const
313
327
  const MEMORY_WRITE_TARGETS = ["long_term", "daily", "state", "user", "review"] as const;
314
328
  const MEMORY_READ_TARGETS = ["long_term", "scratchpad", "daily", "list", "user", "state", "review", "all"] as const;
315
329
  const MEMORY_EDIT_ACTIONS = ["read", "add", "replace", "remove", "replace_all", "compact"] as const;
330
+ const MEMORY_VERSION_RESTORE_TARGETS = ["memory", "skill-drafts", "all"] as const;
316
331
  const STRUCTURED_MEMORY_TYPES = ["fact", "preference", "event", "temporary", "quota", "review"] as const;
317
332
 
318
333
  type StructuredMemoryTarget = (typeof STRUCTURED_MEMORY_TARGETS)[number];
319
334
  type MemoryWriteTarget = (typeof MEMORY_WRITE_TARGETS)[number];
320
335
  type MemoryReadTarget = (typeof MEMORY_READ_TARGETS)[number];
321
336
  type MemoryEditAction = (typeof MEMORY_EDIT_ACTIONS)[number];
337
+ type MemoryVersionRestoreTarget = (typeof MEMORY_VERSION_RESTORE_TARGETS)[number];
322
338
 
323
339
  type StructuredWriteOptions = {
324
340
  target: StructuredMemoryTarget;
@@ -830,6 +846,7 @@ export function buildTransitionHandoff(ctx: ExtensionContext, reason: Transition
830
846
 
831
847
  async function writeTransitionHandoff(ctx: ExtensionContext, reason: TransitionHandoffReason): Promise<boolean> {
832
848
  ensureDirs();
849
+ await evolutionBeforeChange(`session transition handoff ${formatTransitionHandoffReason(reason)}`, "memory: snapshot before handoff", "session", shortSessionId(ctx.sessionManager.getSessionId()));
833
850
  const sid = shortSessionId(ctx.sessionManager.getSessionId());
834
851
  const ts = nowTimestamp();
835
852
  const handoff = buildTransitionHandoff(ctx, reason, sid, ts);
@@ -840,6 +857,7 @@ async function writeTransitionHandoff(ctx: ExtensionContext, reason: TransitionH
840
857
  fs.writeFileSync(filePath, existing + separator + handoff, "utf-8");
841
858
  await ensureQmdAvailableForUpdate();
842
859
  await runQmdUpdateNow();
860
+ await evolutionAfterChange("memory: sync after handoff");
843
861
  return true;
844
862
  }
845
863
 
@@ -1423,6 +1441,7 @@ function getSnapshotMode(): "stable" | "per-turn" {
1423
1441
 
1424
1442
  async function runCurator(reason: string): Promise<string> {
1425
1443
  ensureDirs();
1444
+ await evolutionBeforeChange(`curator before ${reason}`, "memory: snapshot before curate", "tool");
1426
1445
  const store = new FileMemoryStore(MEMORY_DIR);
1427
1446
  const result = await runMemoryCuratorOnce({
1428
1447
  memoryStore: store,
@@ -1452,6 +1471,7 @@ async function runCurator(reason: string): Promise<string> {
1452
1471
  await ensureQmdAvailableForUpdate();
1453
1472
  scheduleQmdUpdate();
1454
1473
  }
1474
+ await evolutionAfterChange("memory: sync after curate");
1455
1475
  const notes = [
1456
1476
  memoryResult.created > 0 ? `proposed ${memoryResult.created} memory promotion(s)` : "",
1457
1477
  skillResult.created > 0 ? `proposed ${skillResult.created} skill draft(s)` : "",
@@ -1461,6 +1481,64 @@ async function runCurator(reason: string): Promise<string> {
1461
1481
  return notes.length > 0 ? `${result.summary}; ${notes.join("; ")}` : result.summary;
1462
1482
  }
1463
1483
 
1484
+ async function evolutionBeforeChange(reason: string, commitMessage: string, trigger = "tool", sessionId?: string): Promise<void> {
1485
+ try {
1486
+ createEvolutionSnapshot(currentEvolutionConfig(), { reason, trigger, sessionId, commitMessage });
1487
+ } catch {
1488
+ // Memory operations should not fail just because versioning is unavailable.
1489
+ }
1490
+ }
1491
+
1492
+ async function evolutionAfterChange(commitMessage: string): Promise<void> {
1493
+ try {
1494
+ const config = currentEvolutionConfig();
1495
+ syncEvolutionAfterChange(config, commitMessage);
1496
+ if (config.autoPush) pushEvolution(config);
1497
+ } catch {
1498
+ // Best-effort only; the memory write itself remains authoritative.
1499
+ }
1500
+ }
1501
+
1502
+ function formatEvolutionStatusText(status: ReturnType<typeof getEvolutionGitStatus>): string {
1503
+ return [
1504
+ `enabled: ${status.enabled}`,
1505
+ `repo: ${status.repoDir}`,
1506
+ `initialized: ${status.initialized}`,
1507
+ `branch: ${status.branch || "n/a"}`,
1508
+ `remote: ${status.remote || "n/a"}`,
1509
+ `dirty: ${status.dirty}`,
1510
+ `autoCommit: ${status.autoCommit}`,
1511
+ `autoPush: ${status.autoPush}`,
1512
+ `lastCommit: ${status.lastCommit || "n/a"}`,
1513
+ status.status ? `status:\n${status.status}` : "status: clean",
1514
+ ].join("\n");
1515
+ }
1516
+
1517
+ function startupCuratorHintEnabled(): boolean {
1518
+ const value = process.env.PI_MEMORY_CURATOR_STARTUP_HINT?.trim().toLowerCase();
1519
+ return !value || !["0", "false", "off", "no"].includes(value);
1520
+ }
1521
+
1522
+ function notifyDisabledCuratorService(ctx: ExtensionContext): void {
1523
+ if (!ctx.hasUI || !startupCuratorHintEnabled()) return;
1524
+ try {
1525
+ const result = getCuratorServiceStatus({ memoryDir: MEMORY_DIR, cliPath: new URL("./src/cli.ts", import.meta.url).pathname });
1526
+ if (result.state.enabled) return;
1527
+ ctx.ui.notify(
1528
+ [
1529
+ "Memory self-evolution curator is off.",
1530
+ "It can run daily outside pi to maintain memory lifecycle, review repeated learnings, and propose disabled skill drafts.",
1531
+ "Enable: /memory-curator-enable 03:00",
1532
+ "Status: /memory-curator-status",
1533
+ "Disable: /memory-curator-disable",
1534
+ ].join("\n"),
1535
+ "info",
1536
+ );
1537
+ } catch {
1538
+ // Startup hints should never block memory initialization.
1539
+ }
1540
+ }
1541
+
1464
1542
  /** Reset snapshot state (for testing). */
1465
1543
  export function _resetMemorySnapshot() {
1466
1544
  memorySnapshot = null;
@@ -1475,6 +1553,31 @@ export function _resetMemorySnapshot() {
1475
1553
  // ---------------------------------------------------------------------------
1476
1554
 
1477
1555
  export default function (pi: ExtensionAPI) {
1556
+ const versionedToolCommitMessages: Record<string, string> = {
1557
+ memory_write: "memory: sync after memory_write",
1558
+ memory_edit: "memory: sync after memory_edit",
1559
+ scratchpad: "memory: sync after scratchpad",
1560
+ memory_learning_approve: "memory: sync after learning approve",
1561
+ memory_learning_reject: "memory: sync after learning reject",
1562
+ };
1563
+
1564
+ function shouldVersionToolCall(toolName: string, input: unknown): boolean {
1565
+ if (!(toolName in versionedToolCommitMessages)) return false;
1566
+ if (toolName === "scratchpad" && (input as { action?: string })?.action === "list") return false;
1567
+ if (toolName === "memory_edit" && (input as { action?: string })?.action === "read") return false;
1568
+ return true;
1569
+ }
1570
+
1571
+ pi.on("tool_call", async (event, ctx) => {
1572
+ if (!shouldVersionToolCall(event.toolName, event.input)) return;
1573
+ await evolutionBeforeChange(`${event.toolName} tool`, `memory: snapshot before ${event.toolName}`, "tool", shortSessionId(ctx.sessionManager.getSessionId()));
1574
+ });
1575
+
1576
+ pi.on("tool_result", async (event) => {
1577
+ if (event.isError || !shouldVersionToolCall(event.toolName, event.input)) return;
1578
+ await evolutionAfterChange(versionedToolCommitMessages[event.toolName]);
1579
+ });
1580
+
1478
1581
  // --- session_start: detect qmd, auto-setup collection ---
1479
1582
  pi.on("session_start", async (_event, ctx) => {
1480
1583
  ensureDirs();
@@ -1492,6 +1595,7 @@ export default function (pi: ExtensionAPI) {
1492
1595
  return undefined;
1493
1596
  });
1494
1597
  }
1598
+ notifyDisabledCuratorService(ctx);
1495
1599
 
1496
1600
  qmdAvailable = await detectQmd();
1497
1601
  if (!qmdAvailable) {
@@ -1548,6 +1652,7 @@ export default function (pi: ExtensionAPI) {
1548
1652
  const result = await generateExitSummary(ctx);
1549
1653
  if (result.hasMessages) {
1550
1654
  const summary = result.summary ?? buildExitSummaryFallback(result.error);
1655
+ await evolutionBeforeChange("session shutdown summary", "memory: snapshot before session summary", "session", shortSessionId(ctx.sessionManager.getSessionId()));
1551
1656
  const sid = shortSessionId(ctx.sessionManager.getSessionId());
1552
1657
  const ts = nowTimestamp();
1553
1658
  const entry = formatExitSummaryEntry(summary, reason, sid, ts);
@@ -1558,6 +1663,7 @@ export default function (pi: ExtensionAPI) {
1558
1663
  await runSessionLearningExtractor(ctx);
1559
1664
  await ensureQmdAvailableForUpdate();
1560
1665
  await runQmdUpdateNow();
1666
+ await evolutionAfterChange("memory: sync after session summary");
1561
1667
  }
1562
1668
  }
1563
1669
  } finally {
@@ -1660,6 +1766,7 @@ export default function (pi: ExtensionAPI) {
1660
1766
  // source files) would keep being injected.
1661
1767
  try {
1662
1768
  if (parts.length === 0) return;
1769
+ await evolutionBeforeChange("session before compact handoff", "memory: snapshot before compact handoff", "session", sid);
1663
1770
 
1664
1771
  const handoff = [`<!-- HANDOFF ${ts} [${sid}] -->`, "## Session Handoff", ...parts].join("\n");
1665
1772
 
@@ -1669,6 +1776,7 @@ export default function (pi: ExtensionAPI) {
1669
1776
  fs.writeFileSync(filePath, existing + separator + handoff, "utf-8");
1670
1777
  await ensureQmdAvailableForUpdate();
1671
1778
  scheduleQmdUpdate();
1779
+ await evolutionAfterChange("memory: sync after compact handoff");
1672
1780
  } finally {
1673
1781
  refreshMemorySnapshot("session_before_compact");
1674
1782
  }
@@ -2237,6 +2345,146 @@ export default function (pi: ExtensionAPI) {
2237
2345
  },
2238
2346
  });
2239
2347
 
2348
+ // --- memory versioning tools ---
2349
+ pi.registerTool({
2350
+ name: "memory_version_status",
2351
+ label: "Memory Version Status",
2352
+ description: "View evolution repo status, remote, branch, dirty state, last commit, and auto-push setting.",
2353
+ parameters: Type.Object({}),
2354
+ async execute() {
2355
+ try {
2356
+ const status = getEvolutionGitStatus(currentEvolutionConfig());
2357
+ return { content: [{ type: "text", text: formatEvolutionStatusText(status) }], details: status };
2358
+ } catch (error) {
2359
+ const message = error instanceof Error ? error.message : String(error);
2360
+ return { content: [{ type: "text", text: message }], details: { error: message }, isError: true };
2361
+ }
2362
+ },
2363
+ });
2364
+
2365
+ pi.registerTool({
2366
+ name: "memory_version_snapshot",
2367
+ label: "Memory Version Snapshot",
2368
+ description: "Manually create a memory + skill-drafts snapshot, sync current mirrors, and commit if changed.",
2369
+ parameters: Type.Object({
2370
+ reason: Type.Optional(Type.String({ description: "Snapshot reason" })),
2371
+ }),
2372
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
2373
+ try {
2374
+ const result = createEvolutionSnapshot(currentEvolutionConfig(), {
2375
+ reason: params.reason || "manual snapshot",
2376
+ trigger: "tool",
2377
+ sessionId: shortSessionId(ctx.sessionManager.getSessionId()),
2378
+ commitMessage: "memory: manual snapshot",
2379
+ });
2380
+ if (currentEvolutionConfig().autoPush) pushEvolution(currentEvolutionConfig());
2381
+ return { content: [{ type: "text", text: result.manifest ? `Snapshot ${result.manifest.id}` : `Snapshot skipped: ${result.skipped}` }], details: result };
2382
+ } catch (error) {
2383
+ const message = error instanceof Error ? error.message : String(error);
2384
+ return { content: [{ type: "text", text: message }], details: { error: message }, isError: true };
2385
+ }
2386
+ },
2387
+ });
2388
+
2389
+ pi.registerTool({
2390
+ name: "memory_version_list",
2391
+ label: "Memory Version List",
2392
+ description: "List recent memory evolution snapshots.",
2393
+ parameters: Type.Object({
2394
+ limit: Type.Optional(Type.Number({ description: "Max snapshots to list. Default: 20" })),
2395
+ }),
2396
+ async execute(_toolCallId, params) {
2397
+ try {
2398
+ const manifests = listManifests(currentEvolutionConfig(), params.limit || 20);
2399
+ const text = manifests.length === 0
2400
+ ? "No snapshots found."
2401
+ : manifests.map((manifest) => `- ${manifest.id} ${manifest.createdAt} ${manifest.reason} (${manifest.files.memory} memory files, ${manifest.files.skillDrafts} skill files)`).join("\n");
2402
+ return { content: [{ type: "text", text }], details: { manifests } };
2403
+ } catch (error) {
2404
+ const message = error instanceof Error ? error.message : String(error);
2405
+ return { content: [{ type: "text", text: message }], details: { error: message }, isError: true };
2406
+ }
2407
+ },
2408
+ });
2409
+
2410
+ pi.registerTool({
2411
+ name: "memory_version_restore",
2412
+ label: "Memory Version Restore",
2413
+ description: "Restore memory and/or skill drafts from a snapshot id. Creates a pre-restore snapshot first.",
2414
+ parameters: Type.Object({
2415
+ id: Type.String({ description: "Snapshot id" }),
2416
+ target: Type.Optional(StringEnum(MEMORY_VERSION_RESTORE_TARGETS, { description: "Restore target. Default: all" })),
2417
+ }),
2418
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
2419
+ try {
2420
+ const target = (params.target || "all") as MemoryVersionRestoreTarget;
2421
+ const result = restoreEvolutionSnapshot(currentEvolutionConfig(), params.id, target as RestoreTarget, shortSessionId(ctx.sessionManager.getSessionId()));
2422
+ snapshotDirty = true;
2423
+ return { content: [{ type: "text", text: `Restored ${target} from snapshot ${result.restored.id}.` }], details: result };
2424
+ } catch (error) {
2425
+ const message = error instanceof Error ? error.message : String(error);
2426
+ return { content: [{ type: "text", text: message }], details: { error: message }, isError: true };
2427
+ }
2428
+ },
2429
+ });
2430
+
2431
+ pi.registerTool({
2432
+ name: "memory_version_push",
2433
+ label: "Memory Version Push",
2434
+ description: "Manually push the local evolution repo to GitHub.",
2435
+ parameters: Type.Object({}),
2436
+ async execute() {
2437
+ try {
2438
+ const output = pushEvolution(currentEvolutionConfig());
2439
+ return { content: [{ type: "text", text: output || "Pushed evolution repo." }], details: {} };
2440
+ } catch (error) {
2441
+ const message = error instanceof Error ? error.message : String(error);
2442
+ return { content: [{ type: "text", text: message }], details: { error: message }, isError: true };
2443
+ }
2444
+ },
2445
+ });
2446
+
2447
+ pi.registerCommand("memory-version-status", {
2448
+ description: "Show memory evolution repo status",
2449
+ handler: async (_args, ctx) => ctx.ui.notify(formatEvolutionStatusText(getEvolutionGitStatus(currentEvolutionConfig())), "info"),
2450
+ });
2451
+
2452
+ pi.registerCommand("memory-version-snapshot", {
2453
+ description: "Create a memory evolution snapshot",
2454
+ handler: async (args, ctx) => {
2455
+ const result = createEvolutionSnapshot(currentEvolutionConfig(), { reason: args.trim() || "manual snapshot", trigger: "slash_command", sessionId: shortSessionId(ctx.sessionManager.getSessionId()), commitMessage: "memory: manual snapshot" });
2456
+ ctx.ui.notify(result.manifest ? `Snapshot ${result.manifest.id}` : `Snapshot skipped: ${result.skipped}`, "info");
2457
+ },
2458
+ });
2459
+
2460
+ pi.registerCommand("memory-version-list", {
2461
+ description: "List memory evolution snapshots",
2462
+ handler: async (_args, ctx) => {
2463
+ const manifests = listManifests(currentEvolutionConfig(), 20);
2464
+ ctx.ui.notify(manifests.length === 0 ? "No snapshots found." : manifests.map((manifest) => `${manifest.id} ${manifest.reason}`).join("\n"), "info");
2465
+ },
2466
+ });
2467
+
2468
+ pi.registerCommand("memory-version-restore", {
2469
+ description: "Restore memory evolution snapshot: /memory-version-restore <snapshot-id> [memory|skill-drafts|all]",
2470
+ handler: async (args, ctx) => {
2471
+ const [id, rawTarget] = args.trim().split(/\s+/);
2472
+ if (!id) {
2473
+ ctx.ui.notify("Usage: /memory-version-restore <snapshot-id> [memory|skill-drafts|all]", "error");
2474
+ return;
2475
+ }
2476
+ const target = MEMORY_VERSION_RESTORE_TARGETS.includes(rawTarget as MemoryVersionRestoreTarget) ? rawTarget as MemoryVersionRestoreTarget : "all";
2477
+ const result = restoreEvolutionSnapshot(currentEvolutionConfig(), id, target as RestoreTarget, shortSessionId(ctx.sessionManager.getSessionId()));
2478
+ snapshotDirty = true;
2479
+ ctx.ui.notify(`Restored ${target} from snapshot ${result.restored.id}.`, "info");
2480
+ },
2481
+ });
2482
+
2483
+ pi.registerCommand("memory-version-push", {
2484
+ description: "Push memory evolution repo",
2485
+ handler: async (_args, ctx) => ctx.ui.notify(pushEvolution(currentEvolutionConfig()) || "Pushed evolution repo.", "info"),
2486
+ });
2487
+
2240
2488
  // --- memory_search tool ---
2241
2489
  pi.registerTool({
2242
2490
  name: "memory_search",
@@ -2,6 +2,7 @@
2
2
  import { JsonlAuditLog } from "./curator-core/audit.ts";
3
3
  import { runMemoryCuratorOnce } from "./curator-core/curate.ts";
4
4
  import { FileMemoryStore } from "./curator-store/file-store.ts";
5
+ import { pushEvolution, resolveEvolutionConfig, syncEvolutionAfterChange, createEvolutionSnapshot } from "./evolution/index.ts";
5
6
  import { disableCuratorService, enableCuratorService, getCuratorServiceStatus, resolveMemoryDir } from "./service-controller.ts";
6
7
 
7
8
  function cliPath(): string {
@@ -22,6 +23,8 @@ function usage(): string {
22
23
  return [
23
24
  "Usage:",
24
25
  " jhp-pi-memory-curator run-once [--memory-dir <path>] [--reason <text>] [--dry-run] [--json]",
26
+ " jhp-pi-memory-curator snapshot [--memory-dir <path>] [--reason <text>] [--json]",
27
+ " jhp-pi-memory-curator push [--memory-dir <path>]",
25
28
  " jhp-pi-memory-curator enable [--memory-dir <path>] [--schedule HH:MM]",
26
29
  " jhp-pi-memory-curator disable [--memory-dir <path>]",
27
30
  " jhp-pi-memory-curator status [--memory-dir <path>]",
@@ -38,17 +41,43 @@ async function main(): Promise<void> {
38
41
  }
39
42
 
40
43
  if (command === "run-once") {
44
+ const reason = readOption(args, "--reason") || "cli";
45
+ const config = resolveEvolutionConfig(memoryDir);
46
+ if (!hasFlag(args, "--dry-run")) {
47
+ createEvolutionSnapshot(config, { reason: `curator before ${reason}`, trigger: "external_curator", commitMessage: "memory: snapshot before curator" });
48
+ }
41
49
  const result = await runMemoryCuratorOnce({
42
50
  memoryStore: new FileMemoryStore(memoryDir),
43
51
  auditLog: new JsonlAuditLog(memoryDir),
44
- reason: readOption(args, "--reason") || "cli",
52
+ reason,
45
53
  dryRun: hasFlag(args, "--dry-run"),
46
54
  });
47
- if (hasFlag(args, "--json")) console.log(JSON.stringify(result, null, 2));
55
+ let evolutionCommit = null;
56
+ if (!hasFlag(args, "--dry-run")) {
57
+ evolutionCommit = syncEvolutionAfterChange(config, "memory: sync after external curator");
58
+ if (config.autoPush) pushEvolution(config);
59
+ }
60
+ if (hasFlag(args, "--json")) console.log(JSON.stringify({ ...result, evolutionCommit }, null, 2));
48
61
  else console.log(result.summary);
49
62
  return;
50
63
  }
51
64
 
65
+ if (command === "snapshot") {
66
+ const result = createEvolutionSnapshot(resolveEvolutionConfig(memoryDir), {
67
+ reason: readOption(args, "--reason") || "cli snapshot",
68
+ trigger: "cli",
69
+ commitMessage: "memory: manual snapshot",
70
+ });
71
+ if (hasFlag(args, "--json")) console.log(JSON.stringify(result, null, 2));
72
+ else console.log(result.manifest ? `Snapshot ${result.manifest.id}` : `Snapshot skipped: ${result.skipped}`);
73
+ return;
74
+ }
75
+
76
+ if (command === "push") {
77
+ console.log(pushEvolution(resolveEvolutionConfig(memoryDir)) || "Pushed evolution repo.");
78
+ return;
79
+ }
80
+
52
81
  if (command === "enable" || command === "install-service") {
53
82
  const result = enableCuratorService({ memoryDir, cliPath: cliPath(), schedule: readOption(args, "--schedule") });
54
83
  console.log(result.message);
@@ -0,0 +1,63 @@
1
+ import * as path from "node:path";
2
+
3
+ export interface EvolutionConfig {
4
+ enabled: boolean;
5
+ autoCommit: boolean;
6
+ autoPush: boolean;
7
+ repoDir: string;
8
+ remote: string;
9
+ branch: string;
10
+ memoryDir: string;
11
+ skillDraftsDir: string;
12
+ }
13
+
14
+ type EvolutionEnv = Partial<
15
+ Record<
16
+ | "PI_EVOLUTION_DIR"
17
+ | "PI_EVOLUTION_REMOTE"
18
+ | "PI_EVOLUTION_BRANCH"
19
+ | "PI_EVOLUTION_ENABLED"
20
+ | "PI_EVOLUTION_AUTO_COMMIT"
21
+ | "PI_EVOLUTION_AUTO_PUSH"
22
+ | "HOME"
23
+ | "USERPROFILE"
24
+ | "HOMEDRIVE"
25
+ | "HOMEPATH",
26
+ string | undefined
27
+ >
28
+ >;
29
+
30
+ export const DEFAULT_EVOLUTION_REMOTE = "https://github.com/LRM-Teams/pi-evolution.git";
31
+ export const DEFAULT_EVOLUTION_BRANCH = "main";
32
+
33
+ function homeDir(env: EvolutionEnv): string {
34
+ return env.HOME ?? env.USERPROFILE ?? (env.HOMEDRIVE && env.HOMEPATH ? `${env.HOMEDRIVE}${env.HOMEPATH}` : undefined) ?? "~";
35
+ }
36
+
37
+ function truthy(value: string | undefined, fallback: boolean): boolean {
38
+ if (value === undefined) return fallback;
39
+ const normalized = value.trim().toLowerCase();
40
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
41
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
42
+ return fallback;
43
+ }
44
+
45
+ function expandHome(input: string, env: EvolutionEnv): string {
46
+ if (input === "~") return homeDir(env);
47
+ if (input.startsWith("~/")) return path.join(homeDir(env), input.slice(2));
48
+ return input;
49
+ }
50
+
51
+ export function resolveEvolutionConfig(memoryDir: string, env: EvolutionEnv = process.env): EvolutionConfig {
52
+ const agentDir = path.dirname(memoryDir);
53
+ return {
54
+ enabled: truthy(env.PI_EVOLUTION_ENABLED, true),
55
+ autoCommit: truthy(env.PI_EVOLUTION_AUTO_COMMIT, true),
56
+ autoPush: truthy(env.PI_EVOLUTION_AUTO_PUSH, false),
57
+ repoDir: path.resolve(expandHome(env.PI_EVOLUTION_DIR || path.join(agentDir, "evolution"), env)),
58
+ remote: env.PI_EVOLUTION_REMOTE || DEFAULT_EVOLUTION_REMOTE,
59
+ branch: env.PI_EVOLUTION_BRANCH || DEFAULT_EVOLUTION_BRANCH,
60
+ memoryDir,
61
+ skillDraftsDir: path.join(agentDir, "skill-drafts"),
62
+ };
63
+ }
@@ -0,0 +1,70 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ export function pathExists(filePath: string): boolean {
5
+ try {
6
+ fs.accessSync(filePath);
7
+ return true;
8
+ } catch {
9
+ return false;
10
+ }
11
+ }
12
+
13
+ export function emptyDir(dir: string): void {
14
+ fs.rmSync(dir, { recursive: true, force: true });
15
+ fs.mkdirSync(dir, { recursive: true });
16
+ }
17
+
18
+ export function copyDirContents(source: string, destination: string, options: { exclude?: (relativePath: string, isDirectory: boolean) => boolean } = {}): void {
19
+ fs.mkdirSync(destination, { recursive: true });
20
+ if (!pathExists(source)) return;
21
+ const entries = fs.readdirSync(source, { withFileTypes: true });
22
+ for (const entry of entries) {
23
+ const sourcePath = path.join(source, entry.name);
24
+ const destinationPath = path.join(destination, entry.name);
25
+ const relativePath = entry.name;
26
+ if (options.exclude?.(relativePath, entry.isDirectory())) continue;
27
+ copyPath(sourcePath, destinationPath, options, relativePath);
28
+ }
29
+ }
30
+
31
+ function copyPath(source: string, destination: string, options: { exclude?: (relativePath: string, isDirectory: boolean) => boolean }, relativePath: string): void {
32
+ const stat = fs.lstatSync(source);
33
+ if (options.exclude?.(relativePath, stat.isDirectory())) return;
34
+ if (stat.isDirectory()) {
35
+ fs.mkdirSync(destination, { recursive: true });
36
+ for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
37
+ copyPath(path.join(source, entry.name), path.join(destination, entry.name), options, path.join(relativePath, entry.name));
38
+ }
39
+ return;
40
+ }
41
+ if (stat.isSymbolicLink()) {
42
+ try {
43
+ fs.symlinkSync(fs.readlinkSync(source), destination);
44
+ } catch {
45
+ // If symlink recreation fails (for example on Windows), copy target contents instead.
46
+ fs.copyFileSync(fs.realpathSync(source), destination);
47
+ }
48
+ return;
49
+ }
50
+ if (stat.isFile()) {
51
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
52
+ fs.copyFileSync(source, destination);
53
+ }
54
+ }
55
+
56
+ export function replaceDirFrom(source: string, destination: string): void {
57
+ emptyDir(destination);
58
+ copyDirContents(source, destination);
59
+ }
60
+
61
+ export function countFiles(dir: string): number {
62
+ if (!pathExists(dir)) return 0;
63
+ let count = 0;
64
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
65
+ const entryPath = path.join(dir, entry.name);
66
+ if (entry.isDirectory()) count += countFiles(entryPath);
67
+ else if (entry.isFile() || entry.isSymbolicLink()) count += 1;
68
+ }
69
+ return count;
70
+ }