@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,205 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
3
+ import { resolveAgentRoots, type PiAgentEnv } from "../paths/resolve-roots.ts";
4
+
5
+ export type SkillLifecycleKind = "draft" | "generated" | "enabled";
6
+
7
+ export type SkillLifecycleItem = {
8
+ kind: SkillLifecycleKind;
9
+ id: string;
10
+ name: string;
11
+ description: string;
12
+ path: string;
13
+ enabled: boolean;
14
+ source?: string;
15
+ enabledAt?: string;
16
+ };
17
+
18
+ export type SkillLifecycleList = {
19
+ drafts: SkillLifecycleItem[];
20
+ generated: SkillLifecycleItem[];
21
+ enabled: SkillLifecycleItem[];
22
+ };
23
+
24
+ export type SkillEnableResult = {
25
+ source: SkillLifecycleItem;
26
+ enabled: SkillLifecycleItem;
27
+ path: string;
28
+ created: boolean;
29
+ };
30
+
31
+ export type SkillDisableResult = {
32
+ id: string;
33
+ path: string;
34
+ removed: boolean;
35
+ };
36
+
37
+ type SkillFrontmatter = {
38
+ name: string;
39
+ description: string;
40
+ };
41
+
42
+ const ENABLED_MANIFEST = ".pi-skill-enabled.json";
43
+
44
+ export function listMemorySkills(env: PiAgentEnv = process.env): SkillLifecycleList {
45
+ const roots = resolveAgentRoots(env);
46
+ const skillsDir = roots.skillsDir;
47
+ return {
48
+ drafts: listSkillDir(roots.skillDraftsDir, "draft"),
49
+ generated: listSkillDir(join(skillsDir, "generated"), "generated"),
50
+ enabled: listSkillDir(join(skillsDir, "enabled"), "enabled"),
51
+ };
52
+ }
53
+
54
+ export function enableMemorySkill(input: string, options: { force?: boolean; env?: PiAgentEnv } = {}): SkillEnableResult {
55
+ const env = options.env ?? process.env;
56
+ const roots = resolveAgentRoots(env);
57
+ if (!roots.agentRoot) throw new Error("skill enable requires PI_AGENT_ROOT or Multica agent env");
58
+ const source = resolveSourceSkill(input, env);
59
+ const targetDir = join(roots.skillsDir, "enabled", source.name);
60
+ const targetPath = join(targetDir, "SKILL.md");
61
+ if (existsSync(targetPath) && !options.force) throw new Error(`enabled skill '${source.name}' already exists; pass force to replace it`);
62
+ if (existsSync(targetDir)) rmSync(targetDir, { recursive: true, force: true });
63
+ mkdirSync(targetDir, { recursive: true });
64
+ writeFileSync(targetPath, readFileSync(source.path, "utf-8"), "utf-8");
65
+ const manifest = {
66
+ name: source.name,
67
+ description: source.description,
68
+ source: `${source.kind}:${source.id}`,
69
+ sourcePath: source.path,
70
+ enabledAt: new Date().toISOString(),
71
+ };
72
+ writeFileSync(join(targetDir, ENABLED_MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`, "utf-8");
73
+ appendSkillAudit(roots.memoryDir, { action: "enable", ...manifest, targetPath });
74
+ return {
75
+ source,
76
+ enabled: readSkillItem(targetPath, "enabled")!,
77
+ path: targetPath,
78
+ created: true,
79
+ };
80
+ }
81
+
82
+ export function disableMemorySkill(idOrName: string, env: PiAgentEnv = process.env): SkillDisableResult {
83
+ const roots = resolveAgentRoots(env);
84
+ if (!roots.agentRoot) throw new Error("skill disable requires PI_AGENT_ROOT or Multica agent env");
85
+ const enabled = listSkillDir(join(roots.skillsDir, "enabled"), "enabled");
86
+ const item = enabled.find((candidate) => candidate.id === idOrName || candidate.name === idOrName);
87
+ if (!item) throw new Error(`No enabled skill found for '${idOrName}'.`);
88
+ const skillDir = dirname(item.path);
89
+ rmSync(skillDir, { recursive: true, force: true });
90
+ appendSkillAudit(roots.memoryDir, { action: "disable", name: item.name, id: item.id, targetPath: item.path });
91
+ return { id: item.id, path: item.path, removed: true };
92
+ }
93
+
94
+ export function formatEnabledSkillsForPrompt(env: PiAgentEnv = process.env): string {
95
+ const enabled = listMemorySkills(env).enabled;
96
+ if (enabled.length === 0) return "";
97
+ return [
98
+ "<available_skills>",
99
+ "The following current-agent skills were explicitly enabled by pi-memory. When a task matches, use the read tool to load the SKILL.md at the listed location before applying it.",
100
+ ...enabled.map((skill) => [
101
+ " <skill>",
102
+ ` <name>${escapeXml(skill.name)}</name>`,
103
+ ` <description>${escapeXml(skill.description)}</description>`,
104
+ ` <location>${escapeXml(skill.path)}</location>`,
105
+ " </skill>",
106
+ ].join("\n")),
107
+ "</available_skills>",
108
+ ].join("\n");
109
+ }
110
+
111
+ export function formatSkillList(list: SkillLifecycleList): string {
112
+ const sections: string[] = [];
113
+ for (const [label, items] of [["drafts", list.drafts], ["generated", list.generated], ["enabled", list.enabled]] as const) {
114
+ sections.push(`${label}: ${items.length}`);
115
+ for (const item of items) {
116
+ sections.push(`- ${item.kind}:${item.id} (${item.name}) ${item.description} -> ${item.path}`);
117
+ }
118
+ }
119
+ return sections.join("\n");
120
+ }
121
+
122
+ function resolveSourceSkill(input: string, env: PiAgentEnv): SkillLifecycleItem {
123
+ const roots = resolveAgentRoots(env);
124
+ const [rawKind, rawId] = input.includes(":") ? input.split(/:(.*)/s, 2) : ["", input];
125
+ const kind = rawKind === "draft" || rawKind === "generated" || rawKind === "enabled" ? rawKind : undefined;
126
+ if (rawKind && !kind) throw new Error(`Unknown skill source '${rawKind}'. Use draft:<id> or generated:<id>.`);
127
+ const candidates = [
128
+ ...(kind === undefined || kind === "draft" ? listSkillDir(roots.skillDraftsDir, "draft") : []),
129
+ ...(kind === undefined || kind === "generated" ? listSkillDir(join(roots.skillsDir, "generated"), "generated") : []),
130
+ ...(kind === "enabled" ? listSkillDir(join(roots.skillsDir, "enabled"), "enabled") : []),
131
+ ];
132
+ const id = rawId || rawKind;
133
+ const item = candidates.find((candidate) => candidate.id === id || candidate.name === id);
134
+ if (!item) throw new Error(`No skill found for '${input}'.`);
135
+ if (item.kind === "enabled") throw new Error(`Skill '${item.name}' is already enabled.`);
136
+ return item;
137
+ }
138
+
139
+ function listSkillDir(dir: string, kind: SkillLifecycleKind): SkillLifecycleItem[] {
140
+ if (!existsSync(dir)) return [];
141
+ const items: SkillLifecycleItem[] = [];
142
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
143
+ if (!entry.isDirectory()) continue;
144
+ const skillPath = join(dir, entry.name, "SKILL.md");
145
+ const item = readSkillItem(skillPath, kind, entry.name);
146
+ if (item) items.push(item);
147
+ }
148
+ return items.sort((a, b) => a.id.localeCompare(b.id));
149
+ }
150
+
151
+ function readSkillItem(skillPath: string, kind: SkillLifecycleKind, id = basename(dirname(skillPath))): SkillLifecycleItem | null {
152
+ if (!existsSync(skillPath)) return null;
153
+ const content = readFileSync(skillPath, "utf-8");
154
+ const frontmatter = parseSkillFrontmatter(content);
155
+ if (!frontmatter) return null;
156
+ const manifest = readManifest(dirname(skillPath));
157
+ return {
158
+ kind,
159
+ id,
160
+ name: frontmatter.name,
161
+ description: frontmatter.description,
162
+ path: skillPath,
163
+ enabled: kind === "enabled",
164
+ source: manifest?.source,
165
+ enabledAt: manifest?.enabledAt,
166
+ };
167
+ }
168
+
169
+ function parseSkillFrontmatter(content: string): SkillFrontmatter | null {
170
+ if (!content.startsWith("---\n")) return null;
171
+ const end = content.indexOf("\n---", 4);
172
+ if (end < 0) return null;
173
+ const frontmatter = content.slice(4, end).split("\n");
174
+ const result: Partial<SkillFrontmatter> = {};
175
+ for (const line of frontmatter) {
176
+ const index = line.indexOf(":");
177
+ if (index < 0) continue;
178
+ const key = line.slice(0, index).trim();
179
+ const value = line.slice(index + 1).trim().replace(/^['\"]|['\"]$/g, "");
180
+ if (key === "name") result.name = value;
181
+ if (key === "description") result.description = value;
182
+ }
183
+ if (!result.name || !result.description) return null;
184
+ return { name: result.name, description: result.description };
185
+ }
186
+
187
+ function readManifest(skillDir: string): { source?: string; enabledAt?: string } | null {
188
+ const manifestPath = join(skillDir, ENABLED_MANIFEST);
189
+ if (!existsSync(manifestPath)) return null;
190
+ try {
191
+ return JSON.parse(readFileSync(manifestPath, "utf-8")) as { source?: string; enabledAt?: string };
192
+ } catch {
193
+ return null;
194
+ }
195
+ }
196
+
197
+ function appendSkillAudit(memoryDir: string, entry: Record<string, unknown>): void {
198
+ const auditDir = join(memoryDir, "audit");
199
+ mkdirSync(auditDir, { recursive: true });
200
+ writeFileSync(join(auditDir, "skill-lifecycle.jsonl"), `${JSON.stringify({ timestamp: new Date().toISOString(), ...entry })}\n`, { encoding: "utf-8", flag: "a" });
201
+ }
202
+
203
+ function escapeXml(value: string): string {
204
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\"/g, "&quot;").replace(/'/g, "&apos;");
205
+ }
@@ -0,0 +1,146 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { resolveAgentRoots, type PiAgentEnv } from "../paths/resolve-roots.ts";
4
+ import { receiveDelivery } from "./downflow.ts";
5
+ import { detectSensitivity } from "./sensitivity.ts";
6
+ import type { Delivery } from "./schemas.ts";
7
+
8
+ export type SyncUploadResult = {
9
+ ok: boolean;
10
+ skipped?: string;
11
+ candidates: number;
12
+ feedback: number;
13
+ profiles: number;
14
+ };
15
+
16
+ export type SyncPullResult = {
17
+ ok: boolean;
18
+ skipped?: string;
19
+ received: number;
20
+ rejected: number;
21
+ written: string[];
22
+ };
23
+
24
+ type UploadCheckpoint = {
25
+ uploaded_at?: string;
26
+ uploadedCandidateIds?: string[];
27
+ feedbackLineCount?: number;
28
+ profiles?: number;
29
+ };
30
+
31
+ export async function syncUpload(env: PiAgentEnv & Record<string, string | undefined> = process.env): Promise<SyncUploadResult> {
32
+ const baseUrl = env.PI_MEMORY_REMOTE_URL?.replace(/\/+$/, "");
33
+ const token = env.PI_MEMORY_REMOTE_TOKEN;
34
+ if (!baseUrl || !token) return { ok: true, skipped: "PI_MEMORY_REMOTE_URL or PI_MEMORY_REMOTE_TOKEN not configured", candidates: 0, feedback: 0, profiles: 0 };
35
+ const roots = resolveAgentRoots(env);
36
+ if (!roots.syncQueueDir || !roots.feedbackDir || !roots.profileDir || !roots.agentId) throw new Error("memory sync upload requires PI_AGENT_ROOT or Multica agent env");
37
+ const checkpointPath = join(roots.syncQueueDir, ".upload-checkpoint.json");
38
+ const checkpoint = readCheckpoint(checkpointPath);
39
+ const uploadedIds = new Set(checkpoint.uploadedCandidateIds || []);
40
+ const memoryCandidates = readJsonl(join(roots.syncQueueDir, "memory-candidates.jsonl"));
41
+ const skillCandidates = readJsonl(join(roots.syncQueueDir, "skill-candidates.jsonl"));
42
+ const candidates = [...memoryCandidates, ...skillCandidates]
43
+ .filter((entry) => detectSensitivity(JSON.stringify(entry)) !== "secret")
44
+ .filter((entry) => !uploadedIds.has(candidateId(entry)));
45
+ const feedbackLines = readJsonlWithCount(join(roots.feedbackDir, "feedback.jsonl"));
46
+ const feedback = feedbackLines.entries
47
+ .slice(checkpoint.feedbackLineCount || 0)
48
+ .filter((entry) => detectSensitivity(JSON.stringify(entry)) !== "secret");
49
+ const profiles = readProfiles(roots.profileDir);
50
+ if (candidates.length > 0) await postJson(`${baseUrl}/api/evolution/submissions`, { candidates }, token);
51
+ if (Object.keys(profiles).length > 0) await postJson(`${baseUrl}/api/agents/${encodeURIComponent(roots.agentId)}/evolution-profile`, { profiles }, token);
52
+ if (feedback.length > 0) await postJson(`${baseUrl}/api/evolution/feedback`, { feedback }, token);
53
+ const nextUploadedIds = [...uploadedIds, ...candidates.map(candidateId).filter(Boolean)];
54
+ writeCheckpoint(checkpointPath, {
55
+ uploaded_at: new Date().toISOString(),
56
+ uploadedCandidateIds: [...new Set(nextUploadedIds)],
57
+ feedbackLineCount: feedbackLines.lineCount,
58
+ profiles: Object.keys(profiles).length,
59
+ });
60
+ return { ok: true, candidates: candidates.length, feedback: feedback.length, profiles: Object.keys(profiles).length };
61
+ }
62
+
63
+ export async function syncPull(env: PiAgentEnv & Record<string, string | undefined> = process.env, limit = 20): Promise<SyncPullResult> {
64
+ const baseUrl = env.PI_MEMORY_REMOTE_URL?.replace(/\/+$/, "");
65
+ const token = env.PI_MEMORY_REMOTE_TOKEN;
66
+ const roots = resolveAgentRoots(env);
67
+ if (!baseUrl || !token) return { ok: true, skipped: "PI_MEMORY_REMOTE_URL or PI_MEMORY_REMOTE_TOKEN not configured", received: 0, rejected: 0, written: [] };
68
+ if (!roots.agentId) throw new Error("memory sync pull requires MULTICA_AGENT_ID or PI_AGENT_ROOT-derived agent context");
69
+ const response = await fetch(`${baseUrl}/api/agents/${encodeURIComponent(roots.agentId)}/evolution-deliveries?limit=${encodeURIComponent(String(limit))}`, {
70
+ headers: { authorization: `Bearer ${token}` },
71
+ });
72
+ if (!response.ok) throw new Error(`pull failed: HTTP ${response.status}`);
73
+ const payload = await response.json() as { deliveries?: Delivery[] } | Delivery[];
74
+ const deliveries = Array.isArray(payload) ? payload : payload.deliveries || [];
75
+ let received = 0;
76
+ let rejected = 0;
77
+ const written: string[] = [];
78
+ for (const delivery of deliveries) {
79
+ const result = receiveDelivery(delivery, env);
80
+ if (result.accepted) {
81
+ received += 1;
82
+ written.push(...result.written);
83
+ } else {
84
+ rejected += 1;
85
+ }
86
+ }
87
+ return { ok: true, received, rejected, written };
88
+ }
89
+
90
+ function readJsonl(filePath: string): unknown[] {
91
+ return readJsonlWithCount(filePath).entries;
92
+ }
93
+
94
+ function readJsonlWithCount(filePath: string): { entries: unknown[]; lineCount: number } {
95
+ if (!existsSync(filePath)) return { entries: [], lineCount: 0 };
96
+ const entries: unknown[] = [];
97
+ let lineCount = 0;
98
+ for (const line of readFileSync(filePath, "utf-8").split("\n")) {
99
+ if (!line.trim()) continue;
100
+ lineCount += 1;
101
+ try {
102
+ entries.push(JSON.parse(line));
103
+ } catch {
104
+ // Ignore malformed queue lines; curator/audit can surface them separately.
105
+ }
106
+ }
107
+ return { entries, lineCount };
108
+ }
109
+
110
+ function readProfiles(profileDir: string): Record<string, string> {
111
+ const result: Record<string, string> = {};
112
+ for (const name of ["user-profile.md", "agent-profile.md", "task-profile.md", "capability-profile.md"]) {
113
+ const filePath = join(profileDir, name);
114
+ if (existsSync(filePath)) result[name] = readFileSync(filePath, "utf-8");
115
+ }
116
+ return result;
117
+ }
118
+
119
+ function candidateId(value: unknown): string {
120
+ if (!value || typeof value !== "object") return "";
121
+ const record = value as { local_unit_id?: unknown; signature?: unknown };
122
+ return typeof record.local_unit_id === "string" ? record.local_unit_id : typeof record.signature === "string" ? record.signature : "";
123
+ }
124
+
125
+ function readCheckpoint(filePath: string): UploadCheckpoint {
126
+ if (!existsSync(filePath)) return {};
127
+ try {
128
+ return JSON.parse(readFileSync(filePath, "utf-8")) as UploadCheckpoint;
129
+ } catch {
130
+ return {};
131
+ }
132
+ }
133
+
134
+ async function postJson(url: string, body: unknown, token: string): Promise<void> {
135
+ const response = await fetch(url, {
136
+ method: "POST",
137
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
138
+ body: JSON.stringify(body),
139
+ });
140
+ if (!response.ok) throw new Error(`upload failed: HTTP ${response.status}`);
141
+ }
142
+
143
+ function writeCheckpoint(filePath: string, value: unknown): void {
144
+ mkdirSync(dirname(filePath), { recursive: true });
145
+ writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf-8");
146
+ }
@@ -0,0 +1,54 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { resolveAgentRoots, type PiAgentEnv } from "../paths/resolve-roots.ts";
4
+ import { detectSensitivity } from "./sensitivity.ts";
5
+ import type { Delivery } from "./schemas.ts";
6
+
7
+ export type ReceiveDeliveryResult = {
8
+ written: string[];
9
+ accepted: boolean;
10
+ reason?: string;
11
+ };
12
+
13
+ export function receiveDelivery(delivery: Delivery, env: PiAgentEnv = process.env): ReceiveDeliveryResult {
14
+ const roots = resolveAgentRoots(env);
15
+ if (!roots.inboxDir || !roots.sharedCacheDir || !roots.agentRoot) throw new Error("downflow receive requires PI_AGENT_ROOT or Multica agent env");
16
+ if (detectSensitivity(JSON.stringify(delivery)) === "secret") return { written: [], accepted: false, reason: "secret-like delivery rejected" };
17
+ const id = safeName(delivery.shared_unit_id || delivery.id);
18
+ const written: string[] = [];
19
+ if (delivery.unit_type === "memory") {
20
+ const inboxPath = join(roots.inboxDir, "memory", `${id}.json`);
21
+ const cachePath = join(roots.sharedCacheDir, "memory", `${id}.json`);
22
+ writeJsonIfChanged(inboxPath, delivery);
23
+ writeJsonIfChanged(cachePath, delivery);
24
+ written.push(inboxPath, cachePath);
25
+ return { written, accepted: true };
26
+ }
27
+ const inboxDir = join(roots.inboxDir, "skills", id);
28
+ const generatedDir = join(roots.agentRoot, "skills", "generated", id);
29
+ mkdirSync(inboxDir, { recursive: true });
30
+ mkdirSync(generatedDir, { recursive: true });
31
+ const skillContent = delivery.content.endsWith("\n") ? delivery.content : `${delivery.content}\n`;
32
+ writeTextIfChanged(join(inboxDir, "SKILL.md"), skillContent);
33
+ writeTextIfChanged(join(generatedDir, "SKILL.md"), skillContent);
34
+ writeJsonIfChanged(join(inboxDir, "delivery.json"), delivery);
35
+ written.push(join(inboxDir, "SKILL.md"), join(generatedDir, "SKILL.md"));
36
+ return { written, accepted: true };
37
+ }
38
+
39
+ function safeName(value: string): string {
40
+ return value.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || "delivery";
41
+ }
42
+
43
+ function writeJsonIfChanged(filePath: string, value: unknown): void {
44
+ writeTextIfChanged(filePath, `${JSON.stringify(value, null, 2)}\n`);
45
+ }
46
+
47
+ function writeTextIfChanged(filePath: string, value: string): void {
48
+ mkdirSync(dirname(filePath), { recursive: true });
49
+ if (existsSync(filePath)) {
50
+ // Avoid rewriting cache files on duplicate pulls.
51
+ return;
52
+ }
53
+ writeFileSync(filePath, value, "utf-8");
54
+ }
@@ -0,0 +1,30 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { resolveAgentRoots, type PiAgentEnv } from "../paths/resolve-roots.ts";
4
+ import { detectSensitivity } from "./sensitivity.ts";
5
+ import type { FeedbackEvent } from "./schemas.ts";
6
+
7
+ export function appendFeedbackEvent(event: FeedbackEvent, env: PiAgentEnv = process.env): string {
8
+ const roots = resolveAgentRoots(env);
9
+ if (!roots.feedbackDir) throw new Error("feedback directory requires PI_AGENT_ROOT or Multica agent env");
10
+ const serialized = JSON.stringify(event);
11
+ if (detectSensitivity(serialized) === "secret") throw new Error("feedback event appears to contain a secret");
12
+ const filePath = join(roots.feedbackDir, "feedback.jsonl");
13
+ mkdirSync(dirname(filePath), { recursive: true });
14
+ appendFileSync(filePath, `${serialized}\n`, "utf-8");
15
+ return filePath;
16
+ }
17
+
18
+ export function buildFeedbackEvent(input: Omit<FeedbackEvent, "workspace_id" | "agent_id" | "run_id" | "timestamp"> & Partial<Pick<FeedbackEvent, "workspace_id" | "agent_id" | "run_id" | "timestamp">>, env: PiAgentEnv = process.env): FeedbackEvent {
19
+ const roots = resolveAgentRoots(env);
20
+ const workspaceId = input.workspace_id || roots.workspaceId;
21
+ const agentId = input.agent_id || roots.agentId;
22
+ if (!workspaceId || !agentId) throw new Error("feedback event requires workspace_id and agent_id");
23
+ return {
24
+ ...input,
25
+ workspace_id: workspaceId,
26
+ agent_id: agentId,
27
+ run_id: input.run_id || env.MULTICA_RUN_ID,
28
+ timestamp: input.timestamp || new Date().toISOString(),
29
+ };
30
+ }
@@ -0,0 +1,40 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { resolveAgentRoots, type PiAgentEnv } from "../paths/resolve-roots.ts";
5
+ import { detectSensitivity, redactLocalPaths } from "./sensitivity.ts";
6
+ import type { EvolutionCandidate } from "./schemas.ts";
7
+
8
+ export function appendEvolutionCandidate(input: Omit<EvolutionCandidate, "workspace_id" | "agent_id" | "local_unit_id" | "signature" | "created_at"> & Partial<Pick<EvolutionCandidate, "workspace_id" | "agent_id" | "local_unit_id" | "signature" | "created_at">>, env: PiAgentEnv = process.env): { path: string; candidate: EvolutionCandidate; appended: boolean } {
9
+ const roots = resolveAgentRoots(env);
10
+ if (!roots.syncQueueDir) throw new Error("sync queue requires PI_AGENT_ROOT or Multica agent env");
11
+ const workspaceId = input.workspace_id || roots.workspaceId;
12
+ const agentId = input.agent_id || roots.agentId;
13
+ if (!workspaceId || !agentId) throw new Error("candidate requires workspace_id and agent_id");
14
+ const sensitivity = input.sensitivity || detectSensitivity(input.content);
15
+ if (sensitivity === "secret") throw new Error("secret-like content cannot enter sync_queue");
16
+ const content = sensitivity === "local_path" ? redactLocalPaths(input.content) : input.content;
17
+ const signature = input.signature || stableHash([input.type, content, input.tags.join(",")].join("\n"));
18
+ const candidate: EvolutionCandidate = {
19
+ ...input,
20
+ workspace_id: workspaceId,
21
+ agent_id: agentId,
22
+ local_unit_id: input.local_unit_id || `${input.type}_${signature.slice(0, 12)}`,
23
+ signature,
24
+ content,
25
+ sensitivity,
26
+ created_at: input.created_at || new Date().toISOString(),
27
+ };
28
+ const filePath = join(roots.syncQueueDir, input.type === "skill" ? "skill-candidates.jsonl" : "memory-candidates.jsonl");
29
+ mkdirSync(roots.syncQueueDir, { recursive: true });
30
+ if (existsSync(filePath)) {
31
+ const exists = readFileSync(filePath, "utf-8").split("\n").some((line) => line.includes(`\"local_unit_id\":\"${candidate.local_unit_id}\"`));
32
+ if (exists) return { path: filePath, candidate, appended: false };
33
+ }
34
+ appendFileSync(filePath, `${JSON.stringify(candidate)}\n`, "utf-8");
35
+ return { path: filePath, candidate, appended: true };
36
+ }
37
+
38
+ function stableHash(value: string): string {
39
+ return createHash("sha256").update(value).digest("hex");
40
+ }
@@ -0,0 +1,44 @@
1
+ export type EvolutionUnitType = "memory" | "skill" | "workflow" | "tool_pattern" | "preference";
2
+ export type FeedbackEventType = "injected" | "used" | "ignored" | "success" | "failure" | "conflict";
3
+
4
+ export type EvolutionCandidate = {
5
+ type: EvolutionUnitType;
6
+ workspace_id: string;
7
+ agent_id: string;
8
+ local_unit_id: string;
9
+ signature: string;
10
+ content: string;
11
+ tags: string[];
12
+ source: "local_curator" | "memory_review" | "manual";
13
+ suggested_scope: "agent" | "workspace" | "project" | "team" | "global" | "agent_type";
14
+ status: "candidate" | "uploaded" | "rejected";
15
+ sensitivity?: "none" | "local_path" | "personal" | "secret" | "unknown";
16
+ source_candidate_ids?: string[];
17
+ created_at: string;
18
+ };
19
+
20
+ export type FeedbackEvent = {
21
+ shared_unit_id: string;
22
+ unit_type: "memory" | "skill";
23
+ workspace_id: string;
24
+ agent_id: string;
25
+ run_id?: string;
26
+ task_type?: string;
27
+ event: FeedbackEventType;
28
+ outcome?: "success" | "failure" | "neutral";
29
+ timestamp: string;
30
+ };
31
+
32
+ export type Delivery = {
33
+ id: string;
34
+ shared_unit_id: string;
35
+ unit_type: "memory" | "skill";
36
+ content: string;
37
+ tags?: string[];
38
+ score?: number;
39
+ task_types?: string[];
40
+ tools?: string[];
41
+ project_types?: string[];
42
+ required_tools?: string[];
43
+ metadata?: Record<string, unknown>;
44
+ };
@@ -0,0 +1,18 @@
1
+ const SECRET_PATTERNS: RegExp[] = [
2
+ /\b(?:api[_-]?key|token|secret|password|passwd|authorization|cookie)\b\s*[:=]\s*[^\s]+/i,
3
+ /\b(?:sk|pk|ghp|gho|github_pat)_[A-Za-z0-9_]{16,}\b/,
4
+ /\b[A-Z0-9]{4}(?:-[A-Z0-9]{4}){3,}\b/,
5
+ /\b\d{6}\b.*\b(?:otp|2fa|mfa|code)\b/i,
6
+ ];
7
+
8
+ export function detectSensitivity(text: string): "none" | "local_path" | "secret" {
9
+ if (SECRET_PATTERNS.some((pattern) => pattern.test(text))) return "secret";
10
+ if (/\/(?:home|Users|workspaces)\/[\w.-]+\//.test(text) || /[A-Za-z]:\\Users\\[^\\]+\\/.test(text)) return "local_path";
11
+ return "none";
12
+ }
13
+
14
+ export function redactLocalPaths(text: string): string {
15
+ return text
16
+ .replace(/\/(?:home|Users|workspaces)\/[\w.-]+\//g, "~/")
17
+ .replace(/[A-Za-z]:\\Users\\[^\\]+\\/g, "<user-home>\\");
18
+ }
@@ -0,0 +1,17 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { test } from "node:test";
6
+ import { getCuratorManagerServiceStatus } from "../src/index.ts";
7
+
8
+ test("manager service status defaults to six-hour dirty-root scan schedule", () => {
9
+ const root = mkdtempSync(join(tmpdir(), "pi-memory-manager-service-"));
10
+ const registryPath = join(root, "multica_workspaces", ".pi-curator", "registry.json");
11
+ const result = getCuratorManagerServiceStatus({ registryPath, cliPath: "/tmp/jhp-pi-memory-curator" });
12
+ assert.equal(result.ok, true);
13
+ assert.equal(result.state.enabled, false);
14
+ assert.equal(result.state.schedule, "0 */6 * * *");
15
+ assert.equal(result.state.registryPath, registryPath);
16
+ assert.match(result.message, /Local curator manager service: disabled/);
17
+ });
@@ -0,0 +1,63 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, mkdtempSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { test } from "node:test";
6
+ import { ensureAgentRoot, resolveAgentRoot, resolveMemoryRoot, resolveSkillDraftRoot } from "../src/index.ts";
7
+
8
+ test("resolver keeps standalone fallback roots", () => {
9
+ const env = { HOME: "/home/tester" };
10
+ assert.equal(resolveAgentRoot(env), undefined);
11
+ assert.equal(resolveMemoryRoot(env), "/home/tester/.pi/agent/memory");
12
+ assert.equal(resolveSkillDraftRoot(env), "/home/tester/.pi/agent/skill-drafts");
13
+ });
14
+
15
+ test("resolver honors explicit memory and skill roots", () => {
16
+ const env = {
17
+ HOME: "/home/tester",
18
+ PI_MEMORY_DIR: "/tmp/pi-agent-a/memory",
19
+ PI_SKILL_DRAFTS_DIR: "/tmp/pi-agent-a/skills/drafts",
20
+ };
21
+ assert.equal(resolveMemoryRoot(env), "/tmp/pi-agent-a/memory");
22
+ assert.equal(resolveSkillDraftRoot(env), "/tmp/pi-agent-a/skills/drafts");
23
+ });
24
+
25
+ test("resolver derives Multica agent root without member id in v1", () => {
26
+ const env = {
27
+ HOME: "/home/tester",
28
+ MULTICA_WORKSPACES_ROOT: "/tmp/multica",
29
+ MULTICA_WORKSPACE_ID: "workspace_1",
30
+ MULTICA_AGENT_ID: "agent_a",
31
+ MULTICA_MEMBER_ID: "member_ignored",
32
+ };
33
+ assert.equal(resolveAgentRoot(env), "/tmp/multica/workspace_1/.pi/agents/agent_a");
34
+ assert.equal(resolveMemoryRoot(env), "/tmp/multica/workspace_1/.pi/agents/agent_a/memory");
35
+ assert.equal(resolveSkillDraftRoot(env), "/tmp/multica/workspace_1/.pi/agents/agent_a/skills/drafts");
36
+ });
37
+
38
+ test("ensureAgentRoot initializes Multica local self-evolution layout", () => {
39
+ const root = mkdtempSync(join(tmpdir(), "pi-agent-root-"));
40
+ const agentRoot = join(root, "workspace_1", ".pi", "agents", "agent_a");
41
+ ensureAgentRoot({ PI_AGENT_ROOT: agentRoot, HOME: root });
42
+ for (const rel of [
43
+ "memory/MEMORY.md",
44
+ "memory/USER.md",
45
+ "memory/STATE.md",
46
+ "memory/REVIEW.md",
47
+ "memory/SCRATCHPAD.md",
48
+ "memory/daily",
49
+ "memory/audit",
50
+ "skills/drafts",
51
+ "skills/generated",
52
+ "skills/enabled",
53
+ "inbox/memory",
54
+ "inbox/skills",
55
+ "shared-cache/memory",
56
+ "shared-cache/skills",
57
+ "profile",
58
+ "feedback/feedback.jsonl",
59
+ "sync_queue",
60
+ ]) {
61
+ assert.equal(existsSync(join(agentRoot, rel)), true, rel);
62
+ }
63
+ });
@@ -0,0 +1,36 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { countPendingReviewItems, formatPendingReviewList, listPendingReviewItems } from "../src/index.ts";
4
+
5
+ const reviewText = [
6
+ `[type:review status:proposed id:mem_123 kind:memory_promotion confidence:high promotes_to:user]
7
+ Proposal: Promote reviewed candidate to user.
8
+ Memory: [type:preference]
9
+ Use LSP rename for cross-file refactors.`,
10
+ `[type:review status:proposed id:skill_123 kind:skill_promotion confidence:high promotes_to:/tmp/skills/demo/SKILL.md]
11
+ Title: Demo Skill
12
+ Description: Use when repeated evidence appears.`,
13
+ `[type:review status:approved id:mem_done kind:memory_promotion]
14
+ Already done.`,
15
+ `not valid metadata but should not throw`,
16
+ ].join("\n§\n");
17
+
18
+ test("counts pending memory and skill proposals", () => {
19
+ assert.deepEqual(countPendingReviewItems(reviewText), { memory: 1, skill: 1, incoming: 0, total: 2 });
20
+ });
21
+
22
+ test("lists pending proposals with filters", () => {
23
+ const memory = listPendingReviewItems(reviewText, { type: "memory" });
24
+ assert.equal(memory.length, 1);
25
+ assert.equal(memory[0].id, "mem_123");
26
+ const skill = listPendingReviewItems(reviewText, { type: "skill" });
27
+ assert.equal(skill.length, 1);
28
+ assert.equal(skill[0].id, "skill_123");
29
+ });
30
+
31
+ test("formats pending review list", () => {
32
+ const text = formatPendingReviewList(listPendingReviewItems(reviewText), countPendingReviewItems(reviewText));
33
+ assert.match(text, /1 memory \/ 1 skill/);
34
+ assert.match(text, /mem_123/);
35
+ assert.match(text, /skill_123/);
36
+ });