@lebronj/pi-suite 0.1.4 → 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.
@@ -0,0 +1,132 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import type { EvolutionConfig } from "./config.ts";
5
+ import { pathExists } from "./file-utils.ts";
6
+
7
+ export interface GitStatus {
8
+ repoDir: string;
9
+ initialized: boolean;
10
+ branch: string | null;
11
+ remote: string | null;
12
+ dirty: boolean;
13
+ status: string;
14
+ lastCommit: string | null;
15
+ autoPush: boolean;
16
+ autoCommit: boolean;
17
+ enabled: boolean;
18
+ }
19
+
20
+ export interface GitCommitResult {
21
+ committed: boolean;
22
+ commit?: string;
23
+ message?: string;
24
+ status: string;
25
+ }
26
+
27
+ function runGit(repoDir: string, args: string[], options: { allowFailure?: boolean } = {}): string {
28
+ try {
29
+ return execFileSync("git", args, { cwd: repoDir, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
30
+ } catch (error) {
31
+ if (options.allowFailure) return "";
32
+ throw error;
33
+ }
34
+ }
35
+
36
+ function isGitRepo(repoDir: string): boolean {
37
+ return pathExists(path.join(repoDir, ".git"));
38
+ }
39
+
40
+ function isEmptyDir(repoDir: string): boolean {
41
+ if (!pathExists(repoDir)) return true;
42
+ return fs.readdirSync(repoDir).length === 0;
43
+ }
44
+
45
+ function ensureGitIdentity(repoDir: string): void {
46
+ const name = runGit(repoDir, ["config", "user.name"], { allowFailure: true });
47
+ const email = runGit(repoDir, ["config", "user.email"], { allowFailure: true });
48
+ if (!name) runGit(repoDir, ["config", "user.name", "pi-memory"]);
49
+ if (!email) runGit(repoDir, ["config", "user.email", "pi-memory@local"]);
50
+ }
51
+
52
+ export function ensureEvolutionRepo(config: EvolutionConfig): void {
53
+ if (!config.enabled) return;
54
+ if (pathExists(config.repoDir) && !isGitRepo(config.repoDir) && !isEmptyDir(config.repoDir)) {
55
+ throw new Error(`Evolution directory exists but is not a git repo: ${config.repoDir}`);
56
+ }
57
+ if (!pathExists(config.repoDir)) {
58
+ fs.mkdirSync(path.dirname(config.repoDir), { recursive: true });
59
+ try {
60
+ execFileSync("git", ["clone", "--branch", config.branch, config.remote, config.repoDir], { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
61
+ } catch {
62
+ fs.mkdirSync(config.repoDir, { recursive: true });
63
+ runGit(config.repoDir, ["init", "-b", config.branch], { allowFailure: true });
64
+ if (!isGitRepo(config.repoDir)) runGit(config.repoDir, ["init"]);
65
+ runGit(config.repoDir, ["checkout", "-B", config.branch]);
66
+ runGit(config.repoDir, ["remote", "add", "origin", config.remote], { allowFailure: true });
67
+ }
68
+ } else if (!isGitRepo(config.repoDir)) {
69
+ fs.mkdirSync(config.repoDir, { recursive: true });
70
+ runGit(config.repoDir, ["init", "-b", config.branch], { allowFailure: true });
71
+ if (!isGitRepo(config.repoDir)) runGit(config.repoDir, ["init"]);
72
+ runGit(config.repoDir, ["checkout", "-B", config.branch]);
73
+ }
74
+
75
+ const remote = runGit(config.repoDir, ["remote", "get-url", "origin"], { allowFailure: true });
76
+ if (!remote) runGit(config.repoDir, ["remote", "add", "origin", config.remote]);
77
+ else if (remote !== config.remote) runGit(config.repoDir, ["remote", "set-url", "origin", config.remote]);
78
+
79
+ const branch = runGit(config.repoDir, ["branch", "--show-current"], { allowFailure: true });
80
+ if (!branch) runGit(config.repoDir, ["checkout", "-B", config.branch]);
81
+ ensureGitIdentity(config.repoDir);
82
+ }
83
+
84
+ export function getEvolutionGitStatus(config: EvolutionConfig): GitStatus {
85
+ if (!config.enabled || !isGitRepo(config.repoDir)) {
86
+ return {
87
+ repoDir: config.repoDir,
88
+ initialized: isGitRepo(config.repoDir),
89
+ branch: null,
90
+ remote: null,
91
+ dirty: false,
92
+ status: "",
93
+ lastCommit: null,
94
+ autoPush: config.autoPush,
95
+ autoCommit: config.autoCommit,
96
+ enabled: config.enabled,
97
+ };
98
+ }
99
+ const status = runGit(config.repoDir, ["status", "--short"], { allowFailure: true });
100
+ return {
101
+ repoDir: config.repoDir,
102
+ initialized: true,
103
+ branch: runGit(config.repoDir, ["branch", "--show-current"], { allowFailure: true }) || null,
104
+ remote: runGit(config.repoDir, ["remote", "get-url", "origin"], { allowFailure: true }) || null,
105
+ dirty: Boolean(status),
106
+ status,
107
+ lastCommit: runGit(config.repoDir, ["log", "-1", "--oneline"], { allowFailure: true }) || null,
108
+ autoPush: config.autoPush,
109
+ autoCommit: config.autoCommit,
110
+ enabled: config.enabled,
111
+ };
112
+ }
113
+
114
+ export function commitEvolutionChanges(config: EvolutionConfig, message: string): GitCommitResult {
115
+ ensureEvolutionRepo(config);
116
+ if (!config.autoCommit) {
117
+ const status = runGit(config.repoDir, ["status", "--short"], { allowFailure: true });
118
+ return { committed: false, message: "auto commit disabled", status };
119
+ }
120
+ runGit(config.repoDir, ["add", "memory", "skill-drafts", "snapshots", "manifests"]);
121
+ const status = runGit(config.repoDir, ["status", "--short"], { allowFailure: true });
122
+ if (!status) return { committed: false, status };
123
+ runGit(config.repoDir, ["commit", "-m", message]);
124
+ const commit = runGit(config.repoDir, ["rev-parse", "--short", "HEAD"], { allowFailure: true }) || undefined;
125
+ return { committed: true, commit, status };
126
+ }
127
+
128
+ export function pushEvolution(config: EvolutionConfig): string {
129
+ ensureEvolutionRepo(config);
130
+ const branch = runGit(config.repoDir, ["branch", "--show-current"], { allowFailure: true }) || config.branch;
131
+ return runGit(config.repoDir, ["push", "-u", "origin", branch]);
132
+ }
@@ -0,0 +1,6 @@
1
+ export { DEFAULT_EVOLUTION_BRANCH, DEFAULT_EVOLUTION_REMOTE, resolveEvolutionConfig, type EvolutionConfig } from "./config.ts";
2
+ export { commitEvolutionChanges, ensureEvolutionRepo, getEvolutionGitStatus, pushEvolution, type GitCommitResult, type GitStatus } from "./git.ts";
3
+ export { buildManifest, createSnapshotId, listManifests, readManifest, writeManifest, type EvolutionManifest } from "./manifest.ts";
4
+ export { restoreEvolutionSnapshot, type RestoreResult, type RestoreTarget } from "./restore.ts";
5
+ export { createEvolutionSnapshot, syncEvolutionAfterChange, type SnapshotOptions, type SnapshotResult } from "./snapshot.ts";
6
+ export { syncCurrentToEvolution } from "./sync.ts";
@@ -0,0 +1,68 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { EvolutionConfig } from "./config.ts";
4
+ import { countFiles } from "./file-utils.ts";
5
+
6
+ export interface EvolutionManifest {
7
+ id: string;
8
+ createdAt: string;
9
+ reason: string;
10
+ trigger: string;
11
+ memoryDir: string;
12
+ skillDraftsDir: string;
13
+ repoDir: string;
14
+ sessionId?: string;
15
+ files: {
16
+ memory: number;
17
+ skillDrafts: number;
18
+ };
19
+ }
20
+
21
+ export function createSnapshotId(now = new Date()): string {
22
+ const stamp = now.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "");
23
+ const suffix = Math.random().toString(16).slice(2, 8).padEnd(6, "0");
24
+ return `${stamp}-${suffix}`;
25
+ }
26
+
27
+ export function buildManifest(config: EvolutionConfig, id: string, reason: string, trigger: string, sessionId?: string, now = new Date()): EvolutionManifest {
28
+ return {
29
+ id,
30
+ createdAt: now.toISOString(),
31
+ reason,
32
+ trigger,
33
+ memoryDir: config.memoryDir,
34
+ skillDraftsDir: config.skillDraftsDir,
35
+ repoDir: config.repoDir,
36
+ sessionId,
37
+ files: {
38
+ memory: countFiles(config.memoryDir),
39
+ skillDrafts: countFiles(config.skillDraftsDir),
40
+ },
41
+ };
42
+ }
43
+
44
+ export function writeManifest(config: EvolutionConfig, manifest: EvolutionManifest): void {
45
+ const snapshotDir = path.join(config.repoDir, "snapshots", manifest.id);
46
+ const manifestsDir = path.join(config.repoDir, "manifests");
47
+ fs.mkdirSync(snapshotDir, { recursive: true });
48
+ fs.mkdirSync(manifestsDir, { recursive: true });
49
+ const content = `${JSON.stringify(manifest, null, 2)}\n`;
50
+ fs.writeFileSync(path.join(snapshotDir, "manifest.json"), content, "utf-8");
51
+ fs.writeFileSync(path.join(manifestsDir, `${manifest.id}.json`), content, "utf-8");
52
+ }
53
+
54
+ export function readManifest(config: EvolutionConfig, id: string): EvolutionManifest {
55
+ const filePath = path.join(config.repoDir, "manifests", `${id}.json`);
56
+ return JSON.parse(fs.readFileSync(filePath, "utf-8")) as EvolutionManifest;
57
+ }
58
+
59
+ export function listManifests(config: EvolutionConfig, limit = 20): EvolutionManifest[] {
60
+ const dir = path.join(config.repoDir, "manifests");
61
+ if (!fs.existsSync(dir)) return [];
62
+ return fs.readdirSync(dir)
63
+ .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);
68
+ }
@@ -0,0 +1,44 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { EvolutionConfig } from "./config.ts";
4
+ import { pathExists, replaceDirFrom } from "./file-utils.ts";
5
+ import { pushEvolution } from "./git.ts";
6
+ import { readManifest, type EvolutionManifest } from "./manifest.ts";
7
+ import { createEvolutionSnapshot, syncEvolutionAfterChange, type SnapshotResult } from "./snapshot.ts";
8
+
9
+ export type RestoreTarget = "memory" | "skill-drafts" | "all";
10
+
11
+ export interface RestoreResult {
12
+ restored: EvolutionManifest;
13
+ preRestore: SnapshotResult;
14
+ commit: ReturnType<typeof syncEvolutionAfterChange>;
15
+ pushed: boolean;
16
+ }
17
+
18
+ export function restoreEvolutionSnapshot(config: EvolutionConfig, id: string, target: RestoreTarget = "all", sessionId?: string): RestoreResult {
19
+ if (!config.enabled) throw new Error("Evolution versioning is disabled.");
20
+ const snapshotDir = path.join(config.repoDir, "snapshots", id);
21
+ if (!pathExists(snapshotDir)) throw new Error(`Snapshot not found: ${id}`);
22
+ const manifest = readManifest(config, id);
23
+ const preRestore = createEvolutionSnapshot(config, {
24
+ reason: `pre-restore backup before ${id}`,
25
+ trigger: "restore",
26
+ sessionId,
27
+ commitMessage: `memory: snapshot before restore ${id}`,
28
+ });
29
+ if (target === "memory" || target === "all") {
30
+ replaceDirFrom(path.join(snapshotDir, "memory"), config.memoryDir);
31
+ }
32
+ if (target === "skill-drafts" || target === "all") {
33
+ replaceDirFrom(path.join(snapshotDir, "skill-drafts"), config.skillDraftsDir);
34
+ }
35
+ fs.mkdirSync(config.memoryDir, { recursive: true });
36
+ fs.mkdirSync(config.skillDraftsDir, { recursive: true });
37
+ const commit = syncEvolutionAfterChange(config, `memory: restore snapshot ${id}`);
38
+ let pushed = false;
39
+ if (config.autoPush) {
40
+ pushEvolution(config);
41
+ pushed = true;
42
+ }
43
+ return { restored: manifest, preRestore, commit, pushed };
44
+ }
@@ -0,0 +1,41 @@
1
+ import * as path from "node:path";
2
+ import type { EvolutionConfig } from "./config.ts";
3
+ import { copyDirContents, emptyDir } from "./file-utils.ts";
4
+ import { commitEvolutionChanges, ensureEvolutionRepo, type GitCommitResult } from "./git.ts";
5
+ import { buildManifest, createSnapshotId, writeManifest, type EvolutionManifest } from "./manifest.ts";
6
+ import { syncCurrentToEvolution } from "./sync.ts";
7
+
8
+ export interface SnapshotOptions {
9
+ reason: string;
10
+ trigger?: string;
11
+ sessionId?: string;
12
+ commitMessage?: string;
13
+ }
14
+
15
+ export interface SnapshotResult {
16
+ manifest: EvolutionManifest | null;
17
+ commit: GitCommitResult | null;
18
+ skipped?: string;
19
+ }
20
+
21
+ export function createEvolutionSnapshot(config: EvolutionConfig, options: SnapshotOptions): SnapshotResult {
22
+ if (!config.enabled) return { manifest: null, commit: null, skipped: "disabled" };
23
+ ensureEvolutionRepo(config);
24
+ const id = createSnapshotId();
25
+ const snapshotDir = path.join(config.repoDir, "snapshots", id);
26
+ emptyDir(path.join(snapshotDir, "memory"));
27
+ emptyDir(path.join(snapshotDir, "skill-drafts"));
28
+ copyDirContents(config.memoryDir, path.join(snapshotDir, "memory"));
29
+ copyDirContents(config.skillDraftsDir, path.join(snapshotDir, "skill-drafts"));
30
+ const manifest = buildManifest(config, id, options.reason, options.trigger || "manual", options.sessionId);
31
+ writeManifest(config, manifest);
32
+ syncCurrentToEvolution(config);
33
+ const commit = commitEvolutionChanges(config, options.commitMessage || `memory: snapshot ${id}`);
34
+ return { manifest, commit };
35
+ }
36
+
37
+ export function syncEvolutionAfterChange(config: EvolutionConfig, message: string): GitCommitResult | null {
38
+ if (!config.enabled) return null;
39
+ syncCurrentToEvolution(config);
40
+ return commitEvolutionChanges(config, message);
41
+ }
@@ -0,0 +1,20 @@
1
+ import * as path from "node:path";
2
+ import type { EvolutionConfig } from "./config.ts";
3
+ import { copyDirContents, emptyDir } from "./file-utils.ts";
4
+ import { ensureEvolutionRepo } from "./git.ts";
5
+
6
+ function excludeRepoMetadata(relativePath: string): boolean {
7
+ const normalized = relativePath.replace(/\\/g, "/");
8
+ return normalized === ".git" || normalized.startsWith(".git/");
9
+ }
10
+
11
+ export function syncCurrentToEvolution(config: EvolutionConfig): void {
12
+ if (!config.enabled) return;
13
+ ensureEvolutionRepo(config);
14
+ const memoryMirror = path.join(config.repoDir, "memory");
15
+ const skillDraftsMirror = path.join(config.repoDir, "skill-drafts");
16
+ emptyDir(memoryMirror);
17
+ emptyDir(skillDraftsMirror);
18
+ copyDirContents(config.memoryDir, memoryMirror, { exclude: excludeRepoMetadata });
19
+ copyDirContents(config.skillDraftsDir, skillDraftsMirror, { exclude: excludeRepoMetadata });
20
+ }
@@ -33,3 +33,26 @@ export { validateMemoryPatch, type MemoryPatch } from "./curator-core/patch.ts";
33
33
  export { DEFAULT_MEMORY_DIR, FileMemoryStore } from "./curator-store/file-store.ts";
34
34
  export { ENTRY_DELIMITER, MEMORY_TARGETS, normalizeMemoryTarget, type CuratorState, type MemoryStore, type MemoryTarget } from "./curator-store/types.ts";
35
35
  export { disableCuratorService, enableCuratorService, getCuratorServiceStatus, resolveMemoryDir, type CuratorServiceBackend, type CuratorServiceResult, type CuratorServiceState } from "./service-controller.ts";
36
+ export {
37
+ DEFAULT_EVOLUTION_BRANCH,
38
+ DEFAULT_EVOLUTION_REMOTE,
39
+ commitEvolutionChanges,
40
+ createEvolutionSnapshot,
41
+ createSnapshotId,
42
+ ensureEvolutionRepo,
43
+ getEvolutionGitStatus,
44
+ listManifests,
45
+ pushEvolution,
46
+ resolveEvolutionConfig,
47
+ restoreEvolutionSnapshot,
48
+ syncCurrentToEvolution,
49
+ syncEvolutionAfterChange,
50
+ type EvolutionConfig,
51
+ type EvolutionManifest,
52
+ type GitCommitResult,
53
+ type GitStatus,
54
+ type RestoreResult,
55
+ type RestoreTarget,
56
+ type SnapshotOptions,
57
+ type SnapshotResult,
58
+ } from "./evolution/index.ts";
@@ -0,0 +1,80 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { test } from "node:test";
6
+ import {
7
+ createEvolutionSnapshot,
8
+ getEvolutionGitStatus,
9
+ listManifests,
10
+ resolveEvolutionConfig,
11
+ restoreEvolutionSnapshot,
12
+ syncEvolutionAfterChange,
13
+ } from "../src/index.ts";
14
+
15
+ function testConfig() {
16
+ const root = mkdtempSync(join(tmpdir(), "pi-memory-evolution-"));
17
+ const memoryDir = join(root, "memory");
18
+ const skillDraftsDir = join(root, "skill-drafts");
19
+ const repoDir = join(root, "evolution");
20
+ return {
21
+ root,
22
+ memoryDir,
23
+ skillDraftsDir,
24
+ repoDir,
25
+ config: resolveEvolutionConfig(memoryDir, {
26
+ PI_EVOLUTION_DIR: repoDir,
27
+ PI_EVOLUTION_REMOTE: "https://example.invalid/pi-evolution.git",
28
+ PI_EVOLUTION_AUTO_PUSH: "0",
29
+ HOME: root,
30
+ }),
31
+ };
32
+ }
33
+
34
+ test("creates snapshot, manifest, current mirrors, and git commit", () => {
35
+ const { memoryDir, skillDraftsDir, repoDir, config } = testConfig();
36
+ mkdirSync(memoryDir, { recursive: true });
37
+ mkdirSync(skillDraftsDir, { recursive: true });
38
+ writeFileSync(join(memoryDir, "MEMORY.md"), "stable memory\n", { encoding: "utf-8", flag: "w" });
39
+ writeFileSync(join(skillDraftsDir, "draft.txt"), "draft\n", { encoding: "utf-8", flag: "w" });
40
+
41
+ const result = createEvolutionSnapshot(config, { reason: "test snapshot", trigger: "test", commitMessage: "memory: test snapshot" });
42
+
43
+ assert.ok(result.manifest?.id);
44
+ assert.equal(result.commit?.committed, true);
45
+ assert.equal(existsSync(join(repoDir, "memory", "MEMORY.md")), true);
46
+ assert.equal(existsSync(join(repoDir, "skill-drafts", "draft.txt")), true);
47
+ assert.equal(existsSync(join(repoDir, "snapshots", result.manifest.id, "manifest.json")), true);
48
+ assert.equal(existsSync(join(repoDir, "manifests", `${result.manifest.id}.json`)), true);
49
+ assert.equal(listManifests(config, 1)[0].reason, "test snapshot");
50
+ assert.match(getEvolutionGitStatus(config).lastCommit || "", /memory: test snapshot/);
51
+ });
52
+
53
+ test("sync does not create empty commits when nothing changed", () => {
54
+ const { memoryDir, config } = testConfig();
55
+ mkdirSync(memoryDir, { recursive: true });
56
+ writeFileSync(join(memoryDir, "MEMORY.md"), "stable memory\n", { encoding: "utf-8", flag: "w" });
57
+ createEvolutionSnapshot(config, { reason: "test snapshot", trigger: "test", commitMessage: "memory: first" });
58
+
59
+ const result = syncEvolutionAfterChange(config, "memory: no-op");
60
+
61
+ assert.equal(result?.committed, false);
62
+ });
63
+
64
+ test("restore creates pre-restore backup and restores selected target", () => {
65
+ const { memoryDir, skillDraftsDir, config } = testConfig();
66
+ mkdirSync(memoryDir, { recursive: true });
67
+ mkdirSync(skillDraftsDir, { recursive: true });
68
+ writeFileSync(join(memoryDir, "MEMORY.md"), "before\n", { encoding: "utf-8", flag: "w" });
69
+ writeFileSync(join(skillDraftsDir, "draft.txt"), "draft before\n", { encoding: "utf-8", flag: "w" });
70
+ const snapshot = createEvolutionSnapshot(config, { reason: "restore point", trigger: "test", commitMessage: "memory: restore point" });
71
+ writeFileSync(join(memoryDir, "MEMORY.md"), "after\n", "utf-8");
72
+ writeFileSync(join(skillDraftsDir, "draft.txt"), "draft after\n", "utf-8");
73
+
74
+ const result = restoreEvolutionSnapshot(config, snapshot.manifest?.id || "", "memory");
75
+
76
+ assert.equal(readFileSync(join(memoryDir, "MEMORY.md"), "utf-8"), "before\n");
77
+ assert.equal(readFileSync(join(skillDraftsDir, "draft.txt"), "utf-8"), "draft after\n");
78
+ assert.ok(result.preRestore.manifest?.id);
79
+ assert.match(getEvolutionGitStatus(config).lastCommit || "", /memory: restore snapshot/);
80
+ });