@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.
Files changed (29) hide show
  1. package/README.md +13 -4
  2. package/extensions/goal-mode.ts +261 -33
  3. package/package.json +1 -1
  4. package/skills/pi-skill/SKILL.md +32 -7
  5. package/vendor/pi-memory/README.md +87 -56
  6. package/vendor/pi-memory/index.ts +522 -310
  7. package/vendor/pi-memory/package.json +1 -1
  8. package/vendor/pi-memory/src/cli.ts +56 -32
  9. package/vendor/pi-memory/src/evolution/config.ts +8 -2
  10. package/vendor/pi-memory/src/governance/share-candidates.ts +72 -0
  11. package/vendor/pi-memory/src/index.ts +68 -25
  12. package/vendor/pi-memory/src/learning/review-compact.ts +36 -0
  13. package/vendor/pi-memory/src/learning/review-summary.ts +81 -0
  14. package/vendor/pi-memory/src/manager/local-curator-manager.ts +146 -0
  15. package/vendor/pi-memory/src/paths/resolve-roots.ts +155 -0
  16. package/vendor/pi-memory/src/profile/generator.ts +45 -0
  17. package/vendor/pi-memory/src/service-controller.ts +156 -84
  18. package/vendor/pi-memory/src/skills/lifecycle.ts +205 -0
  19. package/vendor/pi-memory/src/sync/connector.ts +146 -0
  20. package/vendor/pi-memory/src/sync/downflow.ts +54 -0
  21. package/vendor/pi-memory/src/sync/feedback.ts +30 -0
  22. package/vendor/pi-memory/src/sync/queue.ts +40 -0
  23. package/vendor/pi-memory/src/sync/schemas.ts +44 -0
  24. package/vendor/pi-memory/src/sync/sensitivity.ts +18 -0
  25. package/vendor/pi-memory/test/manager-service.test.ts +17 -0
  26. package/vendor/pi-memory/test/resolve-roots.test.ts +63 -0
  27. package/vendor/pi-memory/test/review-summary.test.ts +36 -0
  28. package/vendor/pi-memory/test/skill-lifecycle.test.ts +75 -0
  29. 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 { accessSync, constants, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
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: NodeJS.ProcessEnv = process.env): string {
36
- if (env.PI_MEMORY_DIR) return env.PI_MEMORY_DIR;
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(), `${SERVICE_NAME}.service`);
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(), `${SERVICE_NAME}.timer`);
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 commandPath(command: string): string | undefined {
83
- if (command.includes("/")) {
84
- try {
85
- accessSync(command, constants.X_OK);
86
- return command;
87
- } catch {
88
- return undefined;
89
- }
90
- }
91
- for (const dir of (process.env.PATH || "").split(":")) {
92
- if (!dir) continue;
93
- const candidate = join(dir, command);
94
- try {
95
- accessSync(candidate, constants.X_OK);
96
- return candidate;
97
- } catch {
98
- // keep searching PATH
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
- return undefined;
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 commandPath(command) !== undefined || spawnSync(command, ["--version"], { stdio: "ignore" }).status === 0;
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(memoryDir: string, cliPath: string, schedule: string): void {
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
- "Description=JHP pi memory curator",
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
- "Description=Run JHP pi memory curator daily",
191
+ `Description=Run ${options.description}`,
177
192
  "",
178
193
  "[Timer]",
179
- `OnCalendar=${systemdCalendar(schedule)}`,
194
+ `OnCalendar=${systemdCalendar(options.schedule)}`,
180
195
  "Persistent=true",
181
- "Unit=jhp-pi-memory-curator.service",
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(memoryDir: string, cliPath: string, schedule: string): void {
192
- writeSystemdUnits(memoryDir, cliPath, schedule);
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", `${SERVICE_NAME}.timer`], { stdio: "ignore" });
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", `${SERVICE_NAME}.timer`], { stdio: "ignore" });
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(CRON_MARKER))
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(memoryDir: string, cliPath: string, schedule: string): void {
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 { hour, minute } = parseSchedule(schedule);
235
- removeCronLine();
236
- const invocation = resolveCliInvocation(cliPath);
237
- const command = [invocation.command, ...invocation.args, "run-once", "--memory-dir", memoryDir, "--reason", "cron"].map(shellQuote).join(" ");
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` : ""}${minute} ${hour} * * * ${command} ${CRON_MARKER}\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
- enableSystemd(memoryDir, options.cliPath, schedule);
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 daily ${schedule} memory curation.`, state };
272
+ return { ok: true, backend: "systemd-user", message: `Enabled systemd user timer for memory curation (${schedule}).`, state };
253
273
  }
254
- enableCron(memoryDir, options.cliPath, schedule);
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 timerActive = spawnSync("systemctl", ["--user", "is-active", `${SERVICE_NAME}.timer`], { encoding: "utf-8" });
292
- const serviceActive = spawnSync("systemctl", ["--user", "is-active", `${SERVICE_NAME}.service`], { encoding: "utf-8" });
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
  }