@lebronj/pi-suite 0.1.18 → 0.1.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lebronj/pi-suite",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
4
4
  "description": "JHP's Pi extension suite for team coding workflows",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -40,8 +40,9 @@ Memory files include:
40
40
  - `.curator-state.json`: last curator run state.
41
41
  - `.curator-service.json`: external curator service state.
42
42
  - `audit/curator.jsonl`: curator audit trail.
43
- - Resolved skill draft root `<slug>/SKILL.md`: disabled skill drafts created after explicit approval.
43
+ - Resolved skill draft root `<slug>/SKILL.md`: disabled skill drafts created after explicit approval; skill directories may include supporting files alongside `SKILL.md`.
44
44
  - Multica agent roots also contain `inbox/`, `shared-cache/`, `skills/generated/`, `profile/`, `feedback/feedback.jsonl`, and `sync_queue/`.
45
+ - Skill share candidates are runnable bundles: `sync_queue/skill-candidates.jsonl` is the queue/manifest, while `sync_queue/skill-candidates/<local_unit_id>/` contains `SKILL.md` plus supporting files.
45
46
 
46
47
  Memory tools:
47
48
 
@@ -55,7 +56,7 @@ Memory tools:
55
56
  - `memory_learning_reject`: reject or archive a review candidate/proposal without deleting it.
56
57
  - `memory_skill_drafts`: list proposed skill drafts.
57
58
  - `memory_skill_list`: list current-agent draft, generated, and enabled memory-managed skills.
58
- - `memory_skill_enable`: explicitly enable a `draft:<slug>` or `generated:<id>` skill by copying it into `skills/enabled/<skill-name>/` and auditing the action.
59
+ - `memory_skill_enable`: explicitly enable a `draft:<slug>` or `generated:<id>` skill by copying the full skill directory into `skills/enabled/<skill-name>/` and auditing the action.
59
60
  - `memory_skill_disable`: remove an enabled skill copy while preserving its draft/generated source.
60
61
  - `/memory-skill`: slash command to list/enable/disable current-agent memory-managed skills.
61
62
  - `/memory-review`: slash command to list/show/approve/reject/archive pending memory and skill proposals in the current resolved root.
@@ -91,10 +92,11 @@ Curator and learning behavior:
91
92
  - `memory_curate` scans yesterday's daily log once per content hash into review candidates, then curator lifecycle and proposal rules process those candidates.
92
93
  - Repeated candidates can become proposed memory promotions or proposed disabled skill drafts after `memory_curate`.
93
94
  - Approval is explicit by default: memory proposals write to memory stores; skill proposals write disabled drafts under the resolved skill draft root.
94
- - Draft and generated skills stay disabled until `memory_skill_enable` copies them into `skills/enabled`; enabled skills are injected as `<available_skills>` metadata for the current agent.
95
+ - Draft and generated skills stay disabled until `memory_skill_enable` copies their full directories into `skills/enabled`; enabled skills are injected as `<available_skills>` metadata for the current agent.
95
96
  - Pi session start can show one pending-review hint; disable with `PI_MEMORY_REVIEW_STARTUP_HINT=0`.
96
97
  - Local multi-agent self-evolution supports one Local Curator Manager registry/dirty-root API for many agent roots, plus a manager service that runs `manager-scan` every 6 hours and exits quickly when no root is dirty.
97
98
  - The local loop also covers share candidate queue, profile generation, sync upload/pull, downflow receive cache, generated skills, enabled skill lifecycle, and feedback JSONL helpers.
99
+ - Skill upload/downflow follows Multica's runnable bundle shape: `content` is `SKILL.md`, `files` are supporting files, and `content_hash` covers both.
98
100
  - Server downflow is per-Agent delivery, not broadcast; local receive writes only `inbox/`, `shared-cache/`, or `skills/generated/` and never overwrites formal memory or auto-enables skills.
99
101
  - The curator avoids semantic auto-delete/merge; ambiguous learning stays in review first.
100
102
 
@@ -59,6 +59,8 @@ The extension auto-creates the `pi-memory` qmd collection and path contexts on s
59
59
  profile/user-profile.md agent-profile.md task-profile.md capability-profile.md
60
60
  feedback/feedback.jsonl
61
61
  sync_queue/memory-candidates.jsonl skill-candidates.jsonl
62
+ sync_queue/skill-candidates/<local_unit_id>/SKILL.md # Runnable upload bundle
63
+ sync_queue/skill-candidates/<local_unit_id>/** # Supporting files
62
64
  ```
63
65
 
64
66
  Structured entries are separated by `§` and may start with metadata:
@@ -190,7 +192,7 @@ Approval is explicit by default:
190
192
  - `memory_learning_approve` on a memory proposal writes `MEMORY.md`, `USER.md`, or `STATE.md` depending on the proposal target.
191
193
  - `memory_learning_approve` on a skill proposal writes the current resolved skill draft root and marks the proposal approved.
192
194
  - Skill drafts are disabled. They are not moved into enabled skill directories automatically.
193
- - `memory_skill_enable` explicitly copies a `draft:<slug>` or `generated:<id>` skill into `skills/enabled/<skill-name>/` and writes `memory/audit/skill-lifecycle.jsonl`.
195
+ - `memory_skill_enable` explicitly copies a full `draft:<slug>` or `generated:<id>` skill directory into `skills/enabled/<skill-name>/` and writes `memory/audit/skill-lifecycle.jsonl`.
194
196
  - `memory_skill_disable` removes only the enabled copy; the draft/generated source remains for later review.
195
197
  - Enabled skills are injected as available-skill metadata so the agent can read the corresponding `SKILL.md` when the task matches.
196
198
  - `memory_learning_reject` marks a candidate or proposal as `rejected` or `archived` without deleting it.
@@ -214,11 +216,11 @@ The package includes local primitives for the full local loop:
214
216
 
215
217
  - `ensureAgentRoot()` initializes the scoped directory tree.
216
218
  - `markCurrentRootDirty()` and `scanDirtyRoots()` implement a single Local Curator Manager registry, manager-level locking, stale lock cleanup, and per-root `.curator.lock` processing.
217
- - `generateShareCandidatesFromReview()` and `appendEvolutionCandidate()` write governed share candidates to `sync_queue/` and block secret-like payloads.
219
+ - `generateShareCandidatesFromReview()` and `appendEvolutionCandidate()` write governed share candidates to `sync_queue/` and block secret-like payloads. Skill candidates follow Multica's runnable bundle shape: JSONL contains queue metadata plus `content` as `SKILL.md` and `files` as supporting files, while `sync_queue/skill-candidates/<local_unit_id>/` stores the same runnable directory for inspection/upload.
218
220
  - `generateProfiles()` writes conservative local profiles for remote matching input.
219
221
  - `syncUpload()` / `memory_sync_upload` POST candidates, profiles, and feedback, using a checkpoint to avoid re-uploading prior candidate ids or feedback lines.
220
222
  - `syncPull()` / `memory_sync_pull` pull only current-agent deliveries and call `receiveDelivery()`.
221
- - `receiveDelivery()` writes server downflow only to `inbox/`, `shared-cache/`, or `skills/generated/`; it never overwrites formal memory or enables skills.
223
+ - `receiveDelivery()` writes server downflow only to `inbox/`, `shared-cache/`, or `skills/generated/`; skill deliveries restore `SKILL.md` plus supporting `files`, but never overwrite formal memory or enable skills.
222
224
  - `appendFeedbackEvent()` / `memory_feedback` writes injected/used/ignored/success/failure/conflict events to `feedback/feedback.jsonl` for connector upload.
223
225
 
224
226
  Server delivery is per-agent matching, not broadcast. The local runtime must only pull deliveries for the current `MULTICA_AGENT_ID` and still filter before injection.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhp/pi-memory",
3
- "version": "0.3.13",
3
+ "version": "0.3.14",
4
4
  "description": "Pi coding agent extension for structured time-aware memory with qmd-powered search and curator support",
5
5
  "main": "index.ts",
6
6
  "bin": {
@@ -1,3 +1,5 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
1
3
  import { parseEntry } from "../curator-core/metadata.ts";
2
4
  import type { MemoryStore } from "../curator-store/types.ts";
3
5
  import type { PiAgentEnv } from "../paths/resolve-roots.ts";
@@ -23,9 +25,17 @@ export async function generateShareCandidatesFromReview(memoryStore: MemoryStore
23
25
  }
24
26
  try {
25
27
  const type = parsed.metadata.kind === "skill_promotion" || parsed.metadata.target_hints?.includes("skill") ? "skill" : "memory";
28
+ const sourcePath = type === "skill" ? sourceSkillDir(parsed.metadata.promotes_to) : undefined;
29
+ if (type === "skill" && !sourcePath) {
30
+ result.skipped += 1;
31
+ continue;
32
+ }
26
33
  const appended = appendEvolutionCandidate({
27
34
  type,
28
35
  content,
36
+ name: parsed.metadata.name,
37
+ description: parsed.metadata.description,
38
+ source_path: sourcePath,
29
39
  tags: tagsFromEntry(parsed.metadata.tags || parsed.metadata.kind || "memory"),
30
40
  source: "local_curator",
31
41
  suggested_scope: suggestedScope(parsed.metadata.scope),
@@ -66,6 +76,12 @@ function tagsFromEntry(value: string): string[] {
66
76
  return value.split(/[ ,#]+/).map((tag) => tag.trim()).filter(Boolean).slice(0, 8);
67
77
  }
68
78
 
79
+ function sourceSkillDir(promotesTo: string | undefined): string | undefined {
80
+ if (!promotesTo || !promotesTo.replace(/\\/g, "/").endsWith("/SKILL.md")) return undefined;
81
+ const dir = dirname(promotesTo);
82
+ return existsSync(join(dir, "SKILL.md")) ? dir : undefined;
83
+ }
84
+
69
85
  function suggestedScope(value: string | undefined): "agent" | "workspace" | "project" | "team" | "global" | "agent_type" {
70
86
  if (value === "workspace" || value === "project" || value === "team" || value === "global") return value;
71
87
  return "workspace";
@@ -61,7 +61,7 @@ export function enableMemorySkill(input: string, options: { force?: boolean; env
61
61
  if (existsSync(targetPath) && !options.force) throw new Error(`enabled skill '${source.name}' already exists; pass force to replace it`);
62
62
  if (existsSync(targetDir)) rmSync(targetDir, { recursive: true, force: true });
63
63
  mkdirSync(targetDir, { recursive: true });
64
- writeFileSync(targetPath, readFileSync(source.path, "utf-8"), "utf-8");
64
+ copySkillDirectory(dirname(source.path), targetDir);
65
65
  const manifest = {
66
66
  name: source.name,
67
67
  description: source.description,
@@ -166,6 +166,22 @@ function readSkillItem(skillPath: string, kind: SkillLifecycleKind, id = basenam
166
166
  };
167
167
  }
168
168
 
169
+ function copySkillDirectory(sourceDir: string, targetDir: string): void {
170
+ for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
171
+ if (entry.isSymbolicLink() || entry.name === ENABLED_MANIFEST) continue;
172
+ const sourcePath = join(sourceDir, entry.name);
173
+ const targetPath = join(targetDir, entry.name);
174
+ if (entry.isDirectory()) {
175
+ mkdirSync(targetPath, { recursive: true });
176
+ copySkillDirectory(sourcePath, targetPath);
177
+ continue;
178
+ }
179
+ if (!entry.isFile()) continue;
180
+ mkdirSync(dirname(targetPath), { recursive: true });
181
+ writeFileSync(targetPath, readFileSync(sourcePath));
182
+ }
183
+ }
184
+
169
185
  function parseSkillFrontmatter(content: string): SkillFrontmatter | null {
170
186
  if (!content.startsWith("---\n")) return null;
171
187
  const end = content.indexOf("\n---", 4);
@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
3
3
  import { resolveAgentRoots, type PiAgentEnv } from "../paths/resolve-roots.ts";
4
4
  import { detectSensitivity } from "./sensitivity.ts";
5
5
  import type { Delivery } from "./schemas.ts";
6
+ import { validateSkillBundleFiles, writeSkillBundle } from "./skill-bundle.ts";
6
7
 
7
8
  export type ReceiveDeliveryResult = {
8
9
  written: string[];
@@ -29,10 +30,10 @@ export function receiveDelivery(delivery: Delivery, env: PiAgentEnv = process.en
29
30
  mkdirSync(inboxDir, { recursive: true });
30
31
  mkdirSync(generatedDir, { recursive: true });
31
32
  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);
33
+ const files = validateSkillBundleFiles(delivery.files || []);
34
+ written.push(...writeSkillBundle(inboxDir, skillContent, files));
35
+ written.push(...writeSkillBundle(generatedDir, skillContent, files));
34
36
  writeJsonIfChanged(join(inboxDir, "delivery.json"), delivery);
35
- written.push(join(inboxDir, "SKILL.md"), join(generatedDir, "SKILL.md"));
36
37
  return { written, accepted: true };
37
38
  }
38
39
 
@@ -1,9 +1,10 @@
1
- import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join, relative } from "node:path";
3
3
  import { createHash } from "node:crypto";
4
4
  import { resolveAgentRoots, type PiAgentEnv } from "../paths/resolve-roots.ts";
5
5
  import { detectSensitivity, redactLocalPaths } from "./sensitivity.ts";
6
6
  import type { EvolutionCandidate } from "./schemas.ts";
7
+ import { hashSkillBundle, loadSkillBundle, writeSkillBundle } from "./skill-bundle.ts";
7
8
 
8
9
  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
10
  const roots = resolveAgentRoots(env);
@@ -11,30 +12,56 @@ export function appendEvolutionCandidate(input: Omit<EvolutionCandidate, "worksp
11
12
  const workspaceId = input.workspace_id || roots.workspaceId;
12
13
  const agentId = input.agent_id || roots.agentId;
13
14
  if (!workspaceId || !agentId) throw new Error("candidate requires workspace_id and agent_id");
14
- const sensitivity = input.sensitivity || detectSensitivity(input.content);
15
+
16
+ const skillBundle = input.type === "skill" && input.source_path ? loadSkillBundle(input.source_path) : null;
17
+ const rawContent = skillBundle?.content ?? input.content;
18
+ const allSkillContent = skillBundle ? [skillBundle.content, ...skillBundle.files.map((file) => file.content)].join("\n") : rawContent;
19
+ const sensitivity = input.sensitivity || detectSensitivity(allSkillContent);
15
20
  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"));
21
+ const content = sensitivity === "local_path" ? redactLocalPaths(rawContent) : rawContent;
22
+ const files = sensitivity === "local_path"
23
+ ? skillBundle?.files.map((file) => ({ ...file, content: redactLocalPaths(file.content) }))
24
+ : skillBundle?.files;
25
+ const signature = input.signature || stableHash([input.type, content, files?.map((file) => `${file.path}\0${file.content}`).join("\0") || "", input.tags.join(",")].join("\n"));
26
+ const localUnitId = input.local_unit_id || `${input.type}_${signature.slice(0, 12)}`;
18
27
  const candidate: EvolutionCandidate = {
19
28
  ...input,
20
29
  workspace_id: workspaceId,
21
30
  agent_id: agentId,
22
- local_unit_id: input.local_unit_id || `${input.type}_${signature.slice(0, 12)}`,
31
+ local_unit_id: localUnitId,
23
32
  signature,
24
33
  content,
25
34
  sensitivity,
26
35
  created_at: input.created_at || new Date().toISOString(),
27
36
  };
37
+ if (skillBundle) {
38
+ const bundleDir = join(roots.syncQueueDir, "skill-candidates", localUnitId);
39
+ const written = writeSkillBundle(bundleDir, content, files || []);
40
+ candidate.name = skillBundle.name;
41
+ candidate.description = skillBundle.description;
42
+ candidate.provider = skillBundle.provider;
43
+ candidate.content_hash = hashSkillBundle(content, files || []);
44
+ candidate.files = files || [];
45
+ candidate.bundle_path = relative(roots.syncQueueDir, bundleDir).replace(/\\/g, "/");
46
+ candidate.source_path = candidate.bundle_path;
47
+ writeCandidateManifest(join(bundleDir, "candidate.json"), candidate);
48
+ if (written.length === 0) throw new Error("skill bundle was not written");
49
+ }
28
50
  const filePath = join(roots.syncQueueDir, input.type === "skill" ? "skill-candidates.jsonl" : "memory-candidates.jsonl");
29
51
  mkdirSync(roots.syncQueueDir, { recursive: true });
30
52
  if (existsSync(filePath)) {
31
- const exists = readFileSync(filePath, "utf-8").split("\n").some((line) => line.includes(`\"local_unit_id\":\"${candidate.local_unit_id}\"`));
53
+ const exists = readFileSync(filePath, "utf-8").split("\n").some((line: string) => line.includes(`\"local_unit_id\":\"${candidate.local_unit_id}\"`));
32
54
  if (exists) return { path: filePath, candidate, appended: false };
33
55
  }
34
56
  appendFileSync(filePath, `${JSON.stringify(candidate)}\n`, "utf-8");
35
57
  return { path: filePath, candidate, appended: true };
36
58
  }
37
59
 
60
+ function writeCandidateManifest(filePath: string, candidate: EvolutionCandidate): void {
61
+ mkdirSync(dirname(filePath), { recursive: true });
62
+ writeFileSync(filePath, `${JSON.stringify(candidate, null, 2)}\n`, "utf-8");
63
+ }
64
+
38
65
  function stableHash(value: string): string {
39
66
  return createHash("sha256").update(value).digest("hex");
40
67
  }
@@ -1,6 +1,11 @@
1
1
  export type EvolutionUnitType = "memory" | "skill" | "workflow" | "tool_pattern" | "preference";
2
2
  export type FeedbackEventType = "injected" | "used" | "ignored" | "success" | "failure" | "conflict";
3
3
 
4
+ export type SkillFileData = {
5
+ path: string;
6
+ content: string;
7
+ };
8
+
4
9
  export type EvolutionCandidate = {
5
10
  type: EvolutionUnitType;
6
11
  workspace_id: string;
@@ -15,6 +20,13 @@ export type EvolutionCandidate = {
15
20
  sensitivity?: "none" | "local_path" | "personal" | "secret" | "unknown";
16
21
  source_candidate_ids?: string[];
17
22
  created_at: string;
23
+ name?: string;
24
+ description?: string;
25
+ source_path?: string;
26
+ provider?: "pi";
27
+ content_hash?: string;
28
+ files?: SkillFileData[];
29
+ bundle_path?: string;
18
30
  };
19
31
 
20
32
  export type FeedbackEvent = {
@@ -34,6 +46,12 @@ export type Delivery = {
34
46
  shared_unit_id: string;
35
47
  unit_type: "memory" | "skill";
36
48
  content: string;
49
+ name?: string;
50
+ description?: string;
51
+ files?: SkillFileData[];
52
+ source_path?: string;
53
+ provider?: "pi";
54
+ content_hash?: string;
37
55
  tags?: string[];
38
56
  score?: number;
39
57
  task_types?: string[];
@@ -0,0 +1,150 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
+ import { dirname, join, relative, resolve } from "node:path";
4
+
5
+ export type SkillBundleFile = {
6
+ path: string;
7
+ content: string;
8
+ };
9
+
10
+ export type SkillBundle = {
11
+ name: string;
12
+ description: string;
13
+ content: string;
14
+ source_path: string;
15
+ provider: "pi";
16
+ content_hash: string;
17
+ files: SkillBundleFile[];
18
+ };
19
+
20
+ const MAX_SKILL_FILE_SIZE = 1 << 20;
21
+ const MAX_SKILL_BUNDLE_SIZE = 8 << 20;
22
+ const MAX_SKILL_FILE_COUNT = 128;
23
+
24
+ export function loadSkillBundle(skillDir: string): SkillBundle {
25
+ const resolvedDir = resolve(skillDir);
26
+ const skillPath = join(resolvedDir, "SKILL.md");
27
+ if (!existsSync(skillPath)) throw new Error(`skill bundle requires SKILL.md: ${skillPath}`);
28
+ const content = readBoundedFile(skillPath);
29
+ const frontmatter = parseSkillFrontmatter(content);
30
+ if (!frontmatter.name) throw new Error(`skill SKILL.md must include frontmatter name: ${skillPath}`);
31
+ const files = collectSkillSupportingFiles(resolvedDir);
32
+ return {
33
+ name: frontmatter.name,
34
+ description: frontmatter.description || "",
35
+ content,
36
+ source_path: resolvedDir,
37
+ provider: "pi",
38
+ content_hash: hashSkillBundle(content, files),
39
+ files,
40
+ };
41
+ }
42
+
43
+ export function writeSkillBundle(skillDir: string, content: string, files: SkillBundleFile[] = []): string[] {
44
+ const resolvedDir = resolve(skillDir);
45
+ const written: string[] = [];
46
+ const mainPath = join(resolvedDir, "SKILL.md");
47
+ writeTextIfChanged(mainPath, content.endsWith("\n") ? content : `${content}\n`);
48
+ written.push(mainPath);
49
+ for (const file of validateSkillBundleFiles(files)) {
50
+ const target = join(resolvedDir, file.path);
51
+ writeTextIfChanged(target, file.content);
52
+ written.push(target);
53
+ }
54
+ return written;
55
+ }
56
+
57
+ export function validateSkillBundleFiles(files: SkillBundleFile[] = []): SkillBundleFile[] {
58
+ const valid: SkillBundleFile[] = [];
59
+ for (const file of files) {
60
+ const clean = normalizeSkillFilePath(file.path);
61
+ if (!clean) continue;
62
+ valid.push({ path: clean, content: String(file.content ?? "") });
63
+ }
64
+ return valid.sort((a, b) => a.path.localeCompare(b.path));
65
+ }
66
+
67
+ export function hashSkillBundle(content: string, files: SkillBundleFile[]): string {
68
+ const h = createHash("sha256");
69
+ h.update(content);
70
+ for (const file of validateSkillBundleFiles(files)) {
71
+ h.update(`\0${file.path}\0${file.content}`);
72
+ }
73
+ return `sha256:${h.digest("hex")}`;
74
+ }
75
+
76
+ function collectSkillSupportingFiles(skillDir: string): SkillBundleFile[] {
77
+ const files: SkillBundleFile[] = [];
78
+ let totalSize = 0;
79
+ function walk(dir: string): void {
80
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
81
+ if (entry.isSymbolicLink()) continue;
82
+ if (isIgnoredSkillEntry(entry.name)) continue;
83
+ const fullPath = join(dir, entry.name);
84
+ if (entry.isDirectory()) {
85
+ walk(fullPath);
86
+ continue;
87
+ }
88
+ if (!entry.isFile()) continue;
89
+ const rel = normalizeSkillFilePath(relative(skillDir, fullPath));
90
+ if (!rel) continue;
91
+ const info = statSync(fullPath);
92
+ if (info.size > MAX_SKILL_FILE_SIZE) continue;
93
+ if (files.length >= MAX_SKILL_FILE_COUNT) throw new Error(`local skill exceeds ${MAX_SKILL_FILE_COUNT} files`);
94
+ totalSize += info.size;
95
+ if (totalSize > MAX_SKILL_BUNDLE_SIZE) throw new Error(`local skill exceeds ${MAX_SKILL_BUNDLE_SIZE} bytes in total`);
96
+ files.push({ path: rel, content: readFileSync(fullPath, "utf-8") });
97
+ }
98
+ }
99
+ walk(skillDir);
100
+ return files.sort((a, b) => a.path.localeCompare(b.path));
101
+ }
102
+
103
+ function readBoundedFile(filePath: string): string {
104
+ const info = statSync(filePath);
105
+ if (info.size > MAX_SKILL_FILE_SIZE) throw new Error(`SKILL.md exceeds ${MAX_SKILL_FILE_SIZE} bytes`);
106
+ return readFileSync(filePath, "utf-8");
107
+ }
108
+
109
+ function normalizeSkillFilePath(path: string): string | null {
110
+ const normalized = path.replace(/\\/g, "/").split("/").filter(Boolean).join("/");
111
+ if (!normalized || normalized === "." || normalized.startsWith("../") || normalized.includes("/../")) return null;
112
+ if (normalized.startsWith("/") || normalized.startsWith("~")) return null;
113
+ if (normalized.toLowerCase() === "skill.md") return null;
114
+ return normalized;
115
+ }
116
+
117
+ function isIgnoredSkillEntry(name: string): boolean {
118
+ if (!name || name.startsWith(".")) return true;
119
+ switch (name.toLowerCase()) {
120
+ case "skill.md":
121
+ case "license":
122
+ case "license.md":
123
+ case "license.txt":
124
+ return true;
125
+ default:
126
+ return false;
127
+ }
128
+ }
129
+
130
+ function parseSkillFrontmatter(content: string): { name: string; description: string } {
131
+ if (!content.startsWith("---\n")) return { name: "", description: "" };
132
+ const end = content.indexOf("\n---", 4);
133
+ if (end < 0) return { name: "", description: "" };
134
+ const result: { name: string; description: string } = { name: "", description: "" };
135
+ for (const line of content.slice(4, end).split("\n")) {
136
+ const index = line.indexOf(":");
137
+ if (index < 0) continue;
138
+ const key = line.slice(0, index).trim();
139
+ const value = line.slice(index + 1).trim().replace(/^["']|["']$/g, "");
140
+ if (key === "name") result.name = value;
141
+ if (key === "description") result.description = value;
142
+ }
143
+ return result;
144
+ }
145
+
146
+ function writeTextIfChanged(filePath: string, value: string): void {
147
+ mkdirSync(dirname(filePath), { recursive: true });
148
+ if (existsSync(filePath)) return;
149
+ writeFileSync(filePath, value, "utf-8");
150
+ }
@@ -34,6 +34,8 @@ test("enables and disables a draft skill without deleting the draft", () => {
34
34
  const { agentRoot, env } = agentEnv();
35
35
  const draftDir = join(agentRoot, "skills", "drafts", "draft-one");
36
36
  writeSkill(join(draftDir, "SKILL.md"), "draft-one");
37
+ mkdirSync(join(draftDir, "templates"), { recursive: true });
38
+ writeFileSync(join(draftDir, "templates", "prompt.md"), "supporting file\n", "utf-8");
37
39
 
38
40
  let skills = listMemorySkills(env);
39
41
  assert.equal(skills.drafts.length, 1);
@@ -42,6 +44,7 @@ test("enables and disables a draft skill without deleting the draft", () => {
42
44
  const enabled = enableMemorySkill("draft:draft-one", { env });
43
45
  assert.equal(enabled.enabled.name, "draft-one");
44
46
  assert.equal(existsSync(join(agentRoot, "skills", "enabled", "draft-one", "SKILL.md")), true);
47
+ assert.equal(existsSync(join(agentRoot, "skills", "enabled", "draft-one", "templates", "prompt.md")), true);
45
48
  assert.equal(existsSync(join(agentRoot, "skills", "drafts", "draft-one", "SKILL.md")), true);
46
49
  assert.match(readFileSync(join(agentRoot, "memory", "audit", "skill-lifecycle.jsonl"), "utf-8"), /"action":"enable"/);
47
50
 
@@ -64,11 +67,13 @@ test("enables a generated skill delivery by generated id", () => {
64
67
  shared_unit_id: "unit_skill_1",
65
68
  unit_type: "skill",
66
69
  content: "---\nname: shared-demo\ndescription: Use for tests.\n---\n# Shared Demo\n",
70
+ files: [{ path: "templates/prompt.md", content: "shared supporting file\n" }],
67
71
  }, env);
68
72
 
69
73
  const enabled = enableMemorySkill("generated:unit_skill_1", { env });
70
74
  assert.equal(enabled.source.kind, "generated");
71
75
  assert.equal(enabled.enabled.name, "shared-demo");
76
+ assert.equal(existsSync(join(agentRoot, "skills", "enabled", "shared-demo", "templates", "prompt.md")), true);
72
77
  assert.equal(existsSync(join(agentRoot, "skills", "enabled", "shared-demo", ".pi-skill-enabled.json")), true);
73
78
  const manifest = readFileSync(join(agentRoot, "skills", "enabled", "shared-demo", ".pi-skill-enabled.json"), "utf-8");
74
79
  assert.match(manifest, /generated:unit_skill_1/);
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { existsSync, mkdtempSync, readFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { test } from "node:test";
@@ -48,6 +48,33 @@ test("sync queue writes share candidates and blocks secret-like payloads", () =>
48
48
  }, env), /secret-like content/);
49
49
  });
50
50
 
51
+ test("skill candidates upload a runnable bundle with supporting files", () => {
52
+ const { agentRoot, env } = agentEnv();
53
+ const skillDir = join(agentRoot, "skills", "drafts", "bundle-demo");
54
+ mkdirSync(join(skillDir, "scripts"), { recursive: true });
55
+ writeFileSync(join(skillDir, "SKILL.md"), "---\nname: bundle-demo\ndescription: Use bundle demo.\n---\n# Bundle Demo\n", "utf-8");
56
+ writeFileSync(join(skillDir, "scripts", "run.sh"), "echo bundle\n", "utf-8");
57
+
58
+ const result = appendEvolutionCandidate({
59
+ type: "skill",
60
+ content: "fallback should be replaced by SKILL.md",
61
+ source_path: skillDir,
62
+ tags: ["coding"],
63
+ source: "local_curator",
64
+ suggested_scope: "agent_type",
65
+ status: "candidate",
66
+ }, env);
67
+
68
+ assert.equal(result.appended, true);
69
+ assert.equal(result.candidate.name, "bundle-demo");
70
+ assert.equal(result.candidate.files?.[0]?.path, "scripts/run.sh");
71
+ assert.match(result.candidate.content, /# Bundle Demo/);
72
+ assert.equal(existsSync(join(agentRoot, "sync_queue", "skill-candidates", result.candidate.local_unit_id, "SKILL.md")), true);
73
+ assert.equal(existsSync(join(agentRoot, "sync_queue", "skill-candidates", result.candidate.local_unit_id, "scripts", "run.sh")), true);
74
+ const queue = readFileSync(join(agentRoot, "sync_queue", "skill-candidates.jsonl"), "utf-8");
75
+ assert.match(queue, /"files":\[\{"path":"scripts\/run.sh"/);
76
+ });
77
+
51
78
  test("downflow receive writes only inbox/cache/generated locations", () => {
52
79
  const { agentRoot, env } = agentEnv();
53
80
  const memoryResult = receiveDelivery({
@@ -67,10 +94,13 @@ test("downflow receive writes only inbox/cache/generated locations", () => {
67
94
  shared_unit_id: "unit_skill_1",
68
95
  unit_type: "skill",
69
96
  content: "---\nname: shared-demo\ndescription: Use for tests.\n---\n# Shared Demo\n",
97
+ files: [{ path: "scripts/run.sh", content: "echo shared\n" }],
70
98
  }, env);
71
99
  assert.equal(skillResult.accepted, true);
72
100
  assert.equal(existsSync(join(agentRoot, "inbox", "skills", "unit_skill_1", "SKILL.md")), true);
101
+ assert.equal(existsSync(join(agentRoot, "inbox", "skills", "unit_skill_1", "scripts", "run.sh")), true);
73
102
  assert.equal(existsSync(join(agentRoot, "skills", "generated", "unit_skill_1", "SKILL.md")), true);
103
+ assert.equal(existsSync(join(agentRoot, "skills", "generated", "unit_skill_1", "scripts", "run.sh")), true);
74
104
  assert.equal(existsSync(join(agentRoot, "skills", "enabled", "unit_skill_1", "SKILL.md")), false);
75
105
  });
76
106