@lebronj/pi-suite 0.1.16 → 0.1.18
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 +13 -4
- package/extensions/goal-mode.ts +261 -33
- package/package.json +1 -1
- package/skills/pi-skill/SKILL.md +32 -7
- package/vendor/pi-memory/README.md +87 -56
- package/vendor/pi-memory/index.ts +522 -310
- package/vendor/pi-memory/package.json +1 -1
- package/vendor/pi-memory/src/cli.ts +56 -32
- package/vendor/pi-memory/src/evolution/config.ts +8 -2
- package/vendor/pi-memory/src/governance/share-candidates.ts +72 -0
- package/vendor/pi-memory/src/index.ts +68 -25
- package/vendor/pi-memory/src/learning/review-compact.ts +36 -0
- package/vendor/pi-memory/src/learning/review-summary.ts +81 -0
- package/vendor/pi-memory/src/manager/local-curator-manager.ts +146 -0
- package/vendor/pi-memory/src/paths/resolve-roots.ts +155 -0
- package/vendor/pi-memory/src/profile/generator.ts +45 -0
- package/vendor/pi-memory/src/service-controller.ts +156 -84
- package/vendor/pi-memory/src/skills/lifecycle.ts +205 -0
- package/vendor/pi-memory/src/sync/connector.ts +146 -0
- package/vendor/pi-memory/src/sync/downflow.ts +54 -0
- package/vendor/pi-memory/src/sync/feedback.ts +30 -0
- package/vendor/pi-memory/src/sync/queue.ts +40 -0
- package/vendor/pi-memory/src/sync/schemas.ts +44 -0
- package/vendor/pi-memory/src/sync/sensitivity.ts +18 -0
- package/vendor/pi-memory/test/manager-service.test.ts +17 -0
- package/vendor/pi-memory/test/resolve-roots.test.ts +63 -0
- package/vendor/pi-memory/test/review-summary.test.ts +36 -0
- package/vendor/pi-memory/test/skill-lifecycle.test.ts +75 -0
- package/vendor/pi-memory/test/sync-local-loop.test.ts +101 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export type PiAgentEnv = Partial<
|
|
5
|
+
Record<
|
|
6
|
+
| "PI_AGENT_ROOT"
|
|
7
|
+
| "PI_MEMORY_DIR"
|
|
8
|
+
| "PI_SKILL_DRAFTS_DIR"
|
|
9
|
+
| "PI_AGENT_INBOX_DIR"
|
|
10
|
+
| "PI_AGENT_SHARED_CACHE_DIR"
|
|
11
|
+
| "PI_AGENT_PROFILE_DIR"
|
|
12
|
+
| "PI_AGENT_FEEDBACK_DIR"
|
|
13
|
+
| "PI_AGENT_SYNC_QUEUE_DIR"
|
|
14
|
+
| "MULTICA_WORKSPACES_ROOT"
|
|
15
|
+
| "MULTICA_WORKSPACE_ID"
|
|
16
|
+
| "MULTICA_AGENT_ID"
|
|
17
|
+
| "MULTICA_RUN_ID"
|
|
18
|
+
| "HOME"
|
|
19
|
+
| "USERPROFILE"
|
|
20
|
+
| "HOMEDRIVE"
|
|
21
|
+
| "HOMEPATH",
|
|
22
|
+
string | undefined
|
|
23
|
+
>
|
|
24
|
+
>;
|
|
25
|
+
|
|
26
|
+
export type ResolvedAgentRoots = {
|
|
27
|
+
agentRoot?: string;
|
|
28
|
+
memoryDir: string;
|
|
29
|
+
skillDraftsDir: string;
|
|
30
|
+
skillsDir: string;
|
|
31
|
+
inboxDir?: string;
|
|
32
|
+
sharedCacheDir?: string;
|
|
33
|
+
profileDir?: string;
|
|
34
|
+
feedbackDir?: string;
|
|
35
|
+
syncQueueDir?: string;
|
|
36
|
+
workspaceId?: string;
|
|
37
|
+
agentId?: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function homeDir(env: PiAgentEnv): string {
|
|
41
|
+
return env.HOME ?? env.USERPROFILE ?? (env.HOMEDRIVE && env.HOMEPATH ? `${env.HOMEDRIVE}${env.HOMEPATH}` : undefined) ?? "~";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function expandHome(input: string, env: PiAgentEnv): string {
|
|
45
|
+
if (input === "~") return homeDir(env);
|
|
46
|
+
if (input.startsWith("~/")) return join(homeDir(env), input.slice(2));
|
|
47
|
+
return input;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function cleanSegment(value: string | undefined): string | undefined {
|
|
51
|
+
const trimmed = value?.trim();
|
|
52
|
+
if (!trimmed) return undefined;
|
|
53
|
+
return trimmed.replace(/[\\/\0]/g, "-");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function resolveAgentRoot(env: PiAgentEnv = process.env): string | undefined {
|
|
57
|
+
if (env.PI_AGENT_ROOT?.trim()) return resolve(expandHome(env.PI_AGENT_ROOT.trim(), env));
|
|
58
|
+
const workspaceId = cleanSegment(env.MULTICA_WORKSPACE_ID);
|
|
59
|
+
const agentId = cleanSegment(env.MULTICA_AGENT_ID);
|
|
60
|
+
if (!workspaceId || !agentId) return undefined;
|
|
61
|
+
const workspacesRoot = resolve(expandHome(env.MULTICA_WORKSPACES_ROOT || "~/multica_workspaces", env));
|
|
62
|
+
return join(workspacesRoot, workspaceId, ".pi", "agents", agentId);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function resolveMemoryRoot(env: PiAgentEnv = process.env): string {
|
|
66
|
+
if (env.PI_MEMORY_DIR?.trim()) return resolve(expandHome(env.PI_MEMORY_DIR.trim(), env));
|
|
67
|
+
const agentRoot = resolveAgentRoot(env);
|
|
68
|
+
if (agentRoot) return join(agentRoot, "memory");
|
|
69
|
+
return join(homeDir(env), ".pi", "agent", "memory");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function resolveSkillDraftRoot(env: PiAgentEnv = process.env): string {
|
|
73
|
+
if (env.PI_SKILL_DRAFTS_DIR?.trim()) return resolve(expandHome(env.PI_SKILL_DRAFTS_DIR.trim(), env));
|
|
74
|
+
const agentRoot = resolveAgentRoot(env);
|
|
75
|
+
if (agentRoot) return join(agentRoot, "skills", "drafts");
|
|
76
|
+
return join(homeDir(env), ".pi", "agent", "skill-drafts");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function resolveAgentSubdir(envName: keyof PiAgentEnv, fallbackName: string, env: PiAgentEnv): string | undefined {
|
|
80
|
+
const explicit = env[envName];
|
|
81
|
+
if (explicit?.trim()) return resolve(expandHome(explicit.trim(), env));
|
|
82
|
+
const agentRoot = resolveAgentRoot(env);
|
|
83
|
+
return agentRoot ? join(agentRoot, fallbackName) : undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function resolveInboxDir(env: PiAgentEnv = process.env): string | undefined {
|
|
87
|
+
return resolveAgentSubdir("PI_AGENT_INBOX_DIR", "inbox", env);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function resolveSharedCacheDir(env: PiAgentEnv = process.env): string | undefined {
|
|
91
|
+
return resolveAgentSubdir("PI_AGENT_SHARED_CACHE_DIR", "shared-cache", env);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function resolveProfileDir(env: PiAgentEnv = process.env): string | undefined {
|
|
95
|
+
return resolveAgentSubdir("PI_AGENT_PROFILE_DIR", "profile", env);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function resolveFeedbackDir(env: PiAgentEnv = process.env): string | undefined {
|
|
99
|
+
return resolveAgentSubdir("PI_AGENT_FEEDBACK_DIR", "feedback", env);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function resolveSyncQueueDir(env: PiAgentEnv = process.env): string | undefined {
|
|
103
|
+
return resolveAgentSubdir("PI_AGENT_SYNC_QUEUE_DIR", "sync_queue", env);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function resolveAgentRoots(env: PiAgentEnv = process.env): ResolvedAgentRoots {
|
|
107
|
+
const agentRoot = resolveAgentRoot(env);
|
|
108
|
+
const memoryDir = resolveMemoryRoot(env);
|
|
109
|
+
const skillDraftsDir = resolveSkillDraftRoot(env);
|
|
110
|
+
return {
|
|
111
|
+
agentRoot,
|
|
112
|
+
memoryDir,
|
|
113
|
+
skillDraftsDir,
|
|
114
|
+
skillsDir: agentRoot ? join(agentRoot, "skills") : dirname(skillDraftsDir),
|
|
115
|
+
inboxDir: resolveInboxDir(env),
|
|
116
|
+
sharedCacheDir: resolveSharedCacheDir(env),
|
|
117
|
+
profileDir: resolveProfileDir(env),
|
|
118
|
+
feedbackDir: resolveFeedbackDir(env),
|
|
119
|
+
syncQueueDir: resolveSyncQueueDir(env),
|
|
120
|
+
workspaceId: cleanSegment(env.MULTICA_WORKSPACE_ID),
|
|
121
|
+
agentId: cleanSegment(env.MULTICA_AGENT_ID),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function ensureFile(filePath: string, content = ""): void {
|
|
126
|
+
if (existsSync(filePath)) return;
|
|
127
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
128
|
+
writeFileSync(filePath, content, "utf-8");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function ensureAgentRoot(rootOrEnv: string | PiAgentEnv = process.env): ResolvedAgentRoots {
|
|
132
|
+
const roots = typeof rootOrEnv === "string" ? resolveAgentRoots({ ...process.env, PI_AGENT_ROOT: rootOrEnv }) : resolveAgentRoots(rootOrEnv);
|
|
133
|
+
mkdirSync(roots.memoryDir, { recursive: true });
|
|
134
|
+
mkdirSync(join(roots.memoryDir, "daily"), { recursive: true });
|
|
135
|
+
mkdirSync(join(roots.memoryDir, "audit"), { recursive: true });
|
|
136
|
+
for (const name of ["MEMORY.md", "USER.md", "STATE.md", "REVIEW.md"]) ensureFile(join(roots.memoryDir, name));
|
|
137
|
+
ensureFile(join(roots.memoryDir, "SCRATCHPAD.md"), "# Scratchpad\n");
|
|
138
|
+
ensureFile(join(roots.memoryDir, ".curator-state.json"), "{}\n");
|
|
139
|
+
mkdirSync(roots.skillDraftsDir, { recursive: true });
|
|
140
|
+
|
|
141
|
+
if (roots.agentRoot) {
|
|
142
|
+
mkdirSync(join(roots.agentRoot, "skills", "generated"), { recursive: true });
|
|
143
|
+
mkdirSync(join(roots.agentRoot, "skills", "enabled"), { recursive: true });
|
|
144
|
+
mkdirSync(join(roots.agentRoot, "inbox", "memory"), { recursive: true });
|
|
145
|
+
mkdirSync(join(roots.agentRoot, "inbox", "skills"), { recursive: true });
|
|
146
|
+
mkdirSync(join(roots.agentRoot, "shared-cache", "memory"), { recursive: true });
|
|
147
|
+
mkdirSync(join(roots.agentRoot, "shared-cache", "skills"), { recursive: true });
|
|
148
|
+
mkdirSync(join(roots.agentRoot, "profile"), { recursive: true });
|
|
149
|
+
mkdirSync(join(roots.agentRoot, "sync_queue"), { recursive: true });
|
|
150
|
+
mkdirSync(join(roots.agentRoot, "feedback"), { recursive: true });
|
|
151
|
+
ensureFile(join(roots.agentRoot, "feedback", "feedback.jsonl"));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return roots;
|
|
155
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { resolveAgentRoots, type PiAgentEnv } from "../paths/resolve-roots.ts";
|
|
4
|
+
import { detectSensitivity, redactLocalPaths } from "../sync/sensitivity.ts";
|
|
5
|
+
|
|
6
|
+
export type ProfileGenerationResult = {
|
|
7
|
+
written: string[];
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function generateProfiles(env: PiAgentEnv = process.env): ProfileGenerationResult {
|
|
11
|
+
const roots = resolveAgentRoots(env);
|
|
12
|
+
if (!roots.profileDir) throw new Error("profile generation requires PI_AGENT_ROOT or Multica agent env");
|
|
13
|
+
mkdirSync(roots.profileDir, { recursive: true });
|
|
14
|
+
const memory = readSafe(join(roots.memoryDir, "MEMORY.md"));
|
|
15
|
+
const user = readSafe(join(roots.memoryDir, "USER.md"));
|
|
16
|
+
const review = readSafe(join(roots.memoryDir, "REVIEW.md"));
|
|
17
|
+
const profileInputs = redactLocalPaths([memory, user, review].filter(Boolean).join("\n\n"));
|
|
18
|
+
const safeInputs = detectSensitivity(profileInputs) === "secret" ? "Secret-like content omitted from profile." : profileInputs;
|
|
19
|
+
const written: string[] = [];
|
|
20
|
+
written.push(writeProfile(roots.profileDir, "user-profile.md", ["# User Profile", excerpt(user || safeInputs)]));
|
|
21
|
+
written.push(writeProfile(roots.profileDir, "agent-profile.md", ["# Agent Profile", `Workspace: ${roots.workspaceId || "standalone"}`, `Agent: ${roots.agentId || "standalone"}`]));
|
|
22
|
+
written.push(writeProfile(roots.profileDir, "task-profile.md", ["# Task Profile", excerpt(review || memory)]));
|
|
23
|
+
written.push(writeProfile(roots.profileDir, "capability-profile.md", ["# Capability Profile", "- Memory tools: available", "- Skill drafts: available", "- Multica scoped root: " + (roots.agentRoot ? "yes" : "no")]));
|
|
24
|
+
return { written };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readSafe(filePath: string): string {
|
|
28
|
+
try {
|
|
29
|
+
return readFileSync(filePath, "utf-8");
|
|
30
|
+
} catch {
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function excerpt(value: string): string {
|
|
36
|
+
const trimmed = value.trim();
|
|
37
|
+
if (!trimmed) return "No stable evidence yet.";
|
|
38
|
+
return trimmed.split("\n").filter(Boolean).slice(0, 80).join("\n");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function writeProfile(profileDir: string, name: string, lines: string[]): string {
|
|
42
|
+
const filePath = join(profileDir, name);
|
|
43
|
+
writeFileSync(filePath, `${lines.join("\n").trim()}\n`, "utf-8");
|
|
44
|
+
return filePath;
|
|
45
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
|
+
import { resolveMemoryRoot, type PiAgentEnv } from "./paths/resolve-roots.ts";
|
|
5
6
|
|
|
6
7
|
export type CuratorServiceBackend = "systemd-user" | "cron" | "none";
|
|
7
8
|
|
|
@@ -17,6 +18,17 @@ export type CuratorServiceState = {
|
|
|
17
18
|
lastError?: string;
|
|
18
19
|
};
|
|
19
20
|
|
|
21
|
+
export type CuratorManagerServiceState = Omit<CuratorServiceState, "memoryDir"> & {
|
|
22
|
+
registryPath: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type CuratorManagerServiceResult = {
|
|
26
|
+
ok: boolean;
|
|
27
|
+
backend: CuratorServiceBackend;
|
|
28
|
+
message: string;
|
|
29
|
+
state: CuratorManagerServiceState;
|
|
30
|
+
};
|
|
31
|
+
|
|
20
32
|
export type CuratorServiceResult = {
|
|
21
33
|
ok: boolean;
|
|
22
34
|
backend: CuratorServiceBackend;
|
|
@@ -25,32 +37,38 @@ export type CuratorServiceResult = {
|
|
|
25
37
|
};
|
|
26
38
|
|
|
27
39
|
const SERVICE_NAME = "jhp-pi-memory-curator";
|
|
40
|
+
const MANAGER_SERVICE_NAME = "jhp-pi-memory-curator-manager";
|
|
28
41
|
const DEFAULT_SCHEDULE = "03:00";
|
|
42
|
+
const DEFAULT_MANAGER_SCHEDULE = "0 */6 * * *";
|
|
29
43
|
const CRON_MARKER = "# jhp-pi-memory-curator";
|
|
44
|
+
const MANAGER_CRON_MARKER = "# jhp-pi-memory-curator-manager";
|
|
30
45
|
|
|
31
46
|
function resolveHome(): string {
|
|
32
47
|
return process.env.HOME ?? process.env.USERPROFILE ?? homedir();
|
|
33
48
|
}
|
|
34
49
|
|
|
35
|
-
export function resolveMemoryDir(env:
|
|
36
|
-
|
|
37
|
-
return join(resolveHome(), ".pi", "agent", "memory");
|
|
50
|
+
export function resolveMemoryDir(env: PiAgentEnv = process.env): string {
|
|
51
|
+
return resolveMemoryRoot(env);
|
|
38
52
|
}
|
|
39
53
|
|
|
40
54
|
function statePath(memoryDir: string): string {
|
|
41
55
|
return join(memoryDir, ".curator-service.json");
|
|
42
56
|
}
|
|
43
57
|
|
|
58
|
+
function managerStatePath(registryPath: string): string {
|
|
59
|
+
return join(dirname(registryPath), ".curator-manager-service.json");
|
|
60
|
+
}
|
|
61
|
+
|
|
44
62
|
function systemdUserDir(): string {
|
|
45
63
|
return join(resolveHome(), ".config", "systemd", "user");
|
|
46
64
|
}
|
|
47
65
|
|
|
48
|
-
function servicePath(): string {
|
|
49
|
-
return join(systemdUserDir(), `${
|
|
66
|
+
function servicePath(serviceName = SERVICE_NAME): string {
|
|
67
|
+
return join(systemdUserDir(), `${serviceName}.service`);
|
|
50
68
|
}
|
|
51
69
|
|
|
52
|
-
function timerPath(): string {
|
|
53
|
-
return join(systemdUserDir(), `${
|
|
70
|
+
function timerPath(serviceName = SERVICE_NAME): string {
|
|
71
|
+
return join(systemdUserDir(), `${serviceName}.timer`);
|
|
54
72
|
}
|
|
55
73
|
|
|
56
74
|
function defaultState(memoryDir: string, cliPath: string): CuratorServiceState {
|
|
@@ -79,30 +97,34 @@ function writeState(state: CuratorServiceState): void {
|
|
|
79
97
|
writeFileSync(statePath(state.memoryDir), `${JSON.stringify(state, null, 2)}\n`, "utf-8");
|
|
80
98
|
}
|
|
81
99
|
|
|
82
|
-
function
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
+
function defaultManagerState(registryPath: string, cliPath: string): CuratorManagerServiceState {
|
|
101
|
+
return {
|
|
102
|
+
enabled: false,
|
|
103
|
+
backend: "none",
|
|
104
|
+
schedule: DEFAULT_MANAGER_SCHEDULE,
|
|
105
|
+
serviceName: MANAGER_SERVICE_NAME,
|
|
106
|
+
registryPath,
|
|
107
|
+
cliPath,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function readManagerState(registryPath: string, cliPath: string): CuratorManagerServiceState {
|
|
112
|
+
const path = managerStatePath(registryPath);
|
|
113
|
+
if (!existsSync(path)) return defaultManagerState(registryPath, cliPath);
|
|
114
|
+
try {
|
|
115
|
+
return { ...defaultManagerState(registryPath, cliPath), ...(JSON.parse(readFileSync(path, "utf-8")) as Partial<CuratorManagerServiceState>) };
|
|
116
|
+
} catch {
|
|
117
|
+
return defaultManagerState(registryPath, cliPath);
|
|
100
118
|
}
|
|
101
|
-
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function writeManagerState(state: CuratorManagerServiceState): void {
|
|
122
|
+
mkdirSync(dirname(managerStatePath(state.registryPath)), { recursive: true });
|
|
123
|
+
writeFileSync(managerStatePath(state.registryPath), `${JSON.stringify(state, null, 2)}\n`, "utf-8");
|
|
102
124
|
}
|
|
103
125
|
|
|
104
126
|
function hasCommand(command: string): boolean {
|
|
105
|
-
return
|
|
127
|
+
return spawnSync(command, ["--version"], { stdio: "ignore" }).status === 0;
|
|
106
128
|
}
|
|
107
129
|
|
|
108
130
|
function canUseSystemdUser(): boolean {
|
|
@@ -115,29 +137,6 @@ function shellQuote(value: string): string {
|
|
|
115
137
|
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
116
138
|
}
|
|
117
139
|
|
|
118
|
-
function systemdQuote(value: string): string {
|
|
119
|
-
if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(value)) return value;
|
|
120
|
-
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$")}"`;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
type CliInvocation = {
|
|
124
|
-
command: string;
|
|
125
|
-
args: string[];
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
function resolveCliInvocation(cliPath: string): CliInvocation {
|
|
129
|
-
const normalized = cliPath.replace(/\\/g, "/");
|
|
130
|
-
const isNodeModulesTypeScript = normalized.endsWith(".ts") && normalized.includes("/node_modules/");
|
|
131
|
-
if (!isNodeModulesTypeScript) return { command: process.execPath, args: [cliPath] };
|
|
132
|
-
|
|
133
|
-
const bunPath = commandPath("bun");
|
|
134
|
-
if (bunPath) return { command: bunPath, args: [cliPath] };
|
|
135
|
-
const tsxPath = commandPath("tsx");
|
|
136
|
-
if (tsxPath) return { command: tsxPath, args: [cliPath] };
|
|
137
|
-
|
|
138
|
-
throw new Error(`Cannot install memory curator service: Node cannot run TypeScript files under node_modules (${cliPath}). Install Bun or tsx, or publish a compiled JavaScript curator CLI.`);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
140
|
function parseSchedule(schedule: string): { hour: string; minute: string } {
|
|
142
141
|
const match = /^(\d{1,2}):(\d{2})$/.exec(schedule);
|
|
143
142
|
if (!match) throw new Error(`Invalid schedule '${schedule}'. Expected HH:MM.`);
|
|
@@ -147,38 +146,54 @@ function parseSchedule(schedule: string): { hour: string; minute: string } {
|
|
|
147
146
|
return { hour: String(hour), minute: String(minute) };
|
|
148
147
|
}
|
|
149
148
|
|
|
149
|
+
function parseCronSchedule(schedule: string): string[] {
|
|
150
|
+
const fields = schedule.trim().split(/\s+/);
|
|
151
|
+
if (fields.length !== 5) throw new Error(`Invalid cron schedule '${schedule}'. Expected five cron fields.`);
|
|
152
|
+
return fields;
|
|
153
|
+
}
|
|
154
|
+
|
|
150
155
|
function systemdCalendar(schedule: string): string {
|
|
156
|
+
const fields = schedule.trim().split(/\s+/);
|
|
157
|
+
if (fields.length === 5) {
|
|
158
|
+
const [minute, hour, dayOfMonth, month, dayOfWeek] = fields;
|
|
159
|
+
if (dayOfMonth !== "*" || month !== "*" || dayOfWeek !== "*") throw new Error(`Unsupported systemd schedule '${schedule}'. Use HH:MM or an every-N-hours cron like '0 */6 * * *'.`);
|
|
160
|
+
if (!/^\d{1,2}$/.test(minute)) throw new Error(`Unsupported systemd schedule minute '${minute}'.`);
|
|
161
|
+
const mm = minute.padStart(2, "0");
|
|
162
|
+
if (/^\d{1,2}$/.test(hour)) return `*-*-* ${hour.padStart(2, "0")}:${mm}:00`;
|
|
163
|
+
const step = /^\*\/(\d{1,2})$/.exec(hour)?.[1];
|
|
164
|
+
if (step) return `*-*-* 00/${step}:${mm}:00`;
|
|
165
|
+
if (hour === "*") return `*-*-* *:${mm}:00`;
|
|
166
|
+
throw new Error(`Unsupported systemd schedule hour '${hour}'.`);
|
|
167
|
+
}
|
|
151
168
|
const { hour, minute } = parseSchedule(schedule);
|
|
152
169
|
return `*-*-* ${hour.padStart(2, "0")}:${minute.padStart(2, "0")}:00`;
|
|
153
170
|
}
|
|
154
171
|
|
|
155
|
-
function writeSystemdUnits(
|
|
172
|
+
function writeSystemdUnits(options: { serviceName: string; description: string; execStart: string; schedule: string }): void {
|
|
156
173
|
mkdirSync(systemdUserDir(), { recursive: true });
|
|
157
|
-
const invocation = resolveCliInvocation(cliPath);
|
|
158
|
-
const execStart = [invocation.command, ...invocation.args, "run-once", "--memory-dir", memoryDir, "--reason", "systemd-timer"].map(systemdQuote).join(" ");
|
|
159
174
|
writeFileSync(
|
|
160
|
-
servicePath(),
|
|
175
|
+
servicePath(options.serviceName),
|
|
161
176
|
[
|
|
162
177
|
"[Unit]",
|
|
163
|
-
|
|
178
|
+
`Description=${options.description}`,
|
|
164
179
|
"",
|
|
165
180
|
"[Service]",
|
|
166
181
|
"Type=oneshot",
|
|
167
|
-
`ExecStart=${execStart}`,
|
|
182
|
+
`ExecStart=${options.execStart}`,
|
|
168
183
|
"",
|
|
169
184
|
].join("\n"),
|
|
170
185
|
"utf-8",
|
|
171
186
|
);
|
|
172
187
|
writeFileSync(
|
|
173
|
-
timerPath(),
|
|
188
|
+
timerPath(options.serviceName),
|
|
174
189
|
[
|
|
175
190
|
"[Unit]",
|
|
176
|
-
|
|
191
|
+
`Description=Run ${options.description}`,
|
|
177
192
|
"",
|
|
178
193
|
"[Timer]",
|
|
179
|
-
`OnCalendar=${systemdCalendar(schedule)}`,
|
|
194
|
+
`OnCalendar=${systemdCalendar(options.schedule)}`,
|
|
180
195
|
"Persistent=true",
|
|
181
|
-
|
|
196
|
+
`Unit=${options.serviceName}.service`,
|
|
182
197
|
"",
|
|
183
198
|
"[Install]",
|
|
184
199
|
"WantedBy=timers.target",
|
|
@@ -188,16 +203,16 @@ function writeSystemdUnits(memoryDir: string, cliPath: string, schedule: string)
|
|
|
188
203
|
);
|
|
189
204
|
}
|
|
190
205
|
|
|
191
|
-
function enableSystemd(
|
|
192
|
-
writeSystemdUnits(
|
|
206
|
+
function enableSystemd(serviceName: string, description: string, execStart: string, schedule: string): void {
|
|
207
|
+
writeSystemdUnits({ serviceName, description, execStart, schedule });
|
|
193
208
|
execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
|
|
194
|
-
execFileSync("systemctl", ["--user", "enable", "--now", `${
|
|
209
|
+
execFileSync("systemctl", ["--user", "enable", "--now", `${serviceName}.timer`], { stdio: "ignore" });
|
|
195
210
|
}
|
|
196
211
|
|
|
197
|
-
function disableSystemd(): void {
|
|
212
|
+
function disableSystemd(serviceName = SERVICE_NAME): void {
|
|
198
213
|
if (!hasCommand("systemctl")) return;
|
|
199
|
-
spawnSync("systemctl", ["--user", "disable", "--now", `${
|
|
200
|
-
for (const path of [servicePath(), timerPath()]) {
|
|
214
|
+
spawnSync("systemctl", ["--user", "disable", "--now", `${serviceName}.timer`], { stdio: "ignore" });
|
|
215
|
+
for (const path of [servicePath(serviceName), timerPath(serviceName)]) {
|
|
201
216
|
try {
|
|
202
217
|
if (existsSync(path)) unlinkSync(path);
|
|
203
218
|
} catch {
|
|
@@ -218,25 +233,28 @@ function installCrontab(content: string): void {
|
|
|
218
233
|
if (result.status !== 0) throw new Error(result.stderr || "failed to install crontab");
|
|
219
234
|
}
|
|
220
235
|
|
|
221
|
-
function removeCronLine(): void {
|
|
236
|
+
function removeCronLine(marker = CRON_MARKER): void {
|
|
222
237
|
if (!hasCommand("crontab")) return;
|
|
223
238
|
const existing = currentCrontab();
|
|
224
239
|
const next = existing
|
|
225
240
|
.split(/\r?\n/)
|
|
226
|
-
.filter((line) => !line.includes(
|
|
241
|
+
.filter((line) => !line.includes(marker))
|
|
227
242
|
.join("\n")
|
|
228
243
|
.trim();
|
|
229
244
|
installCrontab(next ? `${next}\n` : "");
|
|
230
245
|
}
|
|
231
246
|
|
|
232
|
-
function enableCron(
|
|
247
|
+
function enableCron(command: string, schedule: string, marker = CRON_MARKER): void {
|
|
233
248
|
if (!hasCommand("crontab")) throw new Error("Neither systemd user timers nor crontab are available.");
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
249
|
+
const fields = schedule.includes(":") && schedule.trim().split(/\s+/).length === 1
|
|
250
|
+
? (() => {
|
|
251
|
+
const { hour, minute } = parseSchedule(schedule);
|
|
252
|
+
return [minute, hour, "*", "*", "*"];
|
|
253
|
+
})()
|
|
254
|
+
: parseCronSchedule(schedule);
|
|
255
|
+
removeCronLine(marker);
|
|
238
256
|
const existing = currentCrontab().trim();
|
|
239
|
-
const next = `${existing ? `${existing}\n` : ""}${
|
|
257
|
+
const next = `${existing ? `${existing}\n` : ""}${fields.join(" ")} ${command} ${marker}\n`;
|
|
240
258
|
installCrontab(next);
|
|
241
259
|
}
|
|
242
260
|
|
|
@@ -245,13 +263,15 @@ export function enableCuratorService(options: { memoryDir?: string; cliPath: str
|
|
|
245
263
|
const schedule = options.schedule || DEFAULT_SCHEDULE;
|
|
246
264
|
const baseState = { ...defaultState(memoryDir, options.cliPath), schedule };
|
|
247
265
|
try {
|
|
266
|
+
const command = `${shellQuote(process.execPath)} ${shellQuote(options.cliPath)} run-once --memory-dir ${shellQuote(memoryDir)} --reason cron`;
|
|
248
267
|
if (canUseSystemdUser()) {
|
|
249
|
-
|
|
268
|
+
const execStart = `${process.execPath} ${options.cliPath} run-once --memory-dir ${memoryDir} --reason systemd-timer`;
|
|
269
|
+
enableSystemd(SERVICE_NAME, "JHP pi memory curator", execStart, schedule);
|
|
250
270
|
const state: CuratorServiceState = { ...baseState, enabled: true, backend: "systemd-user", installedAt: new Date().toISOString() };
|
|
251
271
|
writeState(state);
|
|
252
|
-
return { ok: true, backend: "systemd-user", message: `Enabled systemd user timer for
|
|
272
|
+
return { ok: true, backend: "systemd-user", message: `Enabled systemd user timer for memory curation (${schedule}).`, state };
|
|
253
273
|
}
|
|
254
|
-
enableCron(
|
|
274
|
+
enableCron(command, schedule, CRON_MARKER);
|
|
255
275
|
const state: CuratorServiceState = { ...baseState, enabled: true, backend: "cron", installedAt: new Date().toISOString() };
|
|
256
276
|
writeState(state);
|
|
257
277
|
return { ok: true, backend: "cron", message: "Enabled cron job for daily memory curation.", state };
|
|
@@ -266,9 +286,9 @@ export function enableCuratorService(options: { memoryDir?: string; cliPath: str
|
|
|
266
286
|
export function disableCuratorService(options: { memoryDir?: string; cliPath: string }): CuratorServiceResult {
|
|
267
287
|
const memoryDir = options.memoryDir || resolveMemoryDir();
|
|
268
288
|
const previous = readState(memoryDir, options.cliPath);
|
|
269
|
-
disableSystemd();
|
|
289
|
+
disableSystemd(SERVICE_NAME);
|
|
270
290
|
try {
|
|
271
|
-
removeCronLine();
|
|
291
|
+
removeCronLine(CRON_MARKER);
|
|
272
292
|
} catch {
|
|
273
293
|
// best effort cleanup
|
|
274
294
|
}
|
|
@@ -277,6 +297,60 @@ export function disableCuratorService(options: { memoryDir?: string; cliPath: st
|
|
|
277
297
|
return { ok: true, backend: previous.backend, message: "Disabled memory curator service.", state };
|
|
278
298
|
}
|
|
279
299
|
|
|
300
|
+
export function enableCuratorManagerService(options: { registryPath: string; cliPath: string; schedule?: string }): CuratorManagerServiceResult {
|
|
301
|
+
const schedule = options.schedule || DEFAULT_MANAGER_SCHEDULE;
|
|
302
|
+
const baseState = { ...defaultManagerState(options.registryPath, options.cliPath), schedule };
|
|
303
|
+
try {
|
|
304
|
+
mkdirSync(dirname(options.registryPath), { recursive: true });
|
|
305
|
+
const command = `${shellQuote(process.execPath)} ${shellQuote(options.cliPath)} manager-scan --registry ${shellQuote(options.registryPath)}`;
|
|
306
|
+
if (canUseSystemdUser()) {
|
|
307
|
+
const execStart = `${process.execPath} ${options.cliPath} manager-scan --registry ${options.registryPath}`;
|
|
308
|
+
enableSystemd(MANAGER_SERVICE_NAME, "JHP pi memory curator manager", execStart, schedule);
|
|
309
|
+
const state: CuratorManagerServiceState = { ...baseState, enabled: true, backend: "systemd-user", installedAt: new Date().toISOString() };
|
|
310
|
+
writeManagerState(state);
|
|
311
|
+
return { ok: true, backend: "systemd-user", message: `Enabled systemd user timer for local curator manager (${schedule}).`, state };
|
|
312
|
+
}
|
|
313
|
+
enableCron(command, schedule, MANAGER_CRON_MARKER);
|
|
314
|
+
const state: CuratorManagerServiceState = { ...baseState, enabled: true, backend: "cron", installedAt: new Date().toISOString() };
|
|
315
|
+
writeManagerState(state);
|
|
316
|
+
return { ok: true, backend: "cron", message: `Enabled cron job for local curator manager (${schedule}).`, state };
|
|
317
|
+
} catch (error) {
|
|
318
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
319
|
+
const state: CuratorManagerServiceState = { ...baseState, enabled: false, backend: "none", lastError: message };
|
|
320
|
+
writeManagerState(state);
|
|
321
|
+
return { ok: false, backend: "none", message, state };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function disableCuratorManagerService(options: { registryPath: string; cliPath: string }): CuratorManagerServiceResult {
|
|
326
|
+
const previous = readManagerState(options.registryPath, options.cliPath);
|
|
327
|
+
disableSystemd(MANAGER_SERVICE_NAME);
|
|
328
|
+
try {
|
|
329
|
+
removeCronLine(MANAGER_CRON_MARKER);
|
|
330
|
+
} catch {
|
|
331
|
+
// best effort cleanup
|
|
332
|
+
}
|
|
333
|
+
const state: CuratorManagerServiceState = { ...previous, enabled: false, backend: "none", disabledAt: new Date().toISOString() };
|
|
334
|
+
writeManagerState(state);
|
|
335
|
+
return { ok: true, backend: previous.backend, message: "Disabled local curator manager service.", state };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function getCuratorManagerServiceStatus(options: { registryPath: string; cliPath: string }): CuratorManagerServiceResult {
|
|
339
|
+
const state = readManagerState(options.registryPath, options.cliPath);
|
|
340
|
+
const parts = [
|
|
341
|
+
`Local curator manager service: ${state.enabled ? "enabled" : "disabled"}`,
|
|
342
|
+
`Backend: ${state.backend}`,
|
|
343
|
+
`Schedule: ${state.schedule}`,
|
|
344
|
+
`Registry: ${state.registryPath}`,
|
|
345
|
+
];
|
|
346
|
+
if (state.lastError) parts.push(`Last error: ${state.lastError}`);
|
|
347
|
+
if (state.backend === "systemd-user" && hasCommand("systemctl")) {
|
|
348
|
+
const active = spawnSync("systemctl", ["--user", "is-active", `${MANAGER_SERVICE_NAME}.timer`], { encoding: "utf-8" });
|
|
349
|
+
parts.push(`systemd timer active: ${active.stdout.trim() || "unknown"}`);
|
|
350
|
+
}
|
|
351
|
+
return { ok: true, backend: state.backend, message: parts.join("\n"), state };
|
|
352
|
+
}
|
|
353
|
+
|
|
280
354
|
export function getCuratorServiceStatus(options: { memoryDir?: string; cliPath: string }): CuratorServiceResult {
|
|
281
355
|
const memoryDir = options.memoryDir || resolveMemoryDir();
|
|
282
356
|
const state = readState(memoryDir, options.cliPath);
|
|
@@ -288,10 +362,8 @@ export function getCuratorServiceStatus(options: { memoryDir?: string; cliPath:
|
|
|
288
362
|
];
|
|
289
363
|
if (state.lastError) parts.push(`Last error: ${state.lastError}`);
|
|
290
364
|
if (state.backend === "systemd-user" && hasCommand("systemctl")) {
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
parts.push(`systemd timer active: ${timerActive.stdout.trim() || "unknown"}`);
|
|
294
|
-
parts.push(`systemd service active: ${serviceActive.stdout.trim() || "unknown"}`);
|
|
365
|
+
const active = spawnSync("systemctl", ["--user", "is-active", `${SERVICE_NAME}.timer`], { encoding: "utf-8" });
|
|
366
|
+
parts.push(`systemd timer active: ${active.stdout.trim() || "unknown"}`);
|
|
295
367
|
}
|
|
296
368
|
return { ok: true, backend: state.backend, message: parts.join("\n"), state };
|
|
297
369
|
}
|