portable-agent-layer 0.41.1 → 0.43.0

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 (35) hide show
  1. package/.husky/install.mjs +8 -0
  2. package/README.md +2 -1
  3. package/assets/skills/analyze-youtube/SKILL.md +1 -1
  4. package/assets/skills/consulting-report/template/components/code-block.tsx +21 -0
  5. package/assets/skills/consulting-report/template/components/cover-page.tsx +88 -27
  6. package/assets/skills/consulting-report/template/components/decision-table.tsx +62 -0
  7. package/assets/skills/consulting-report/template/components/process-stage.tsx +28 -0
  8. package/assets/skills/consulting-report/template/components/tier-matrix.tsx +102 -0
  9. package/assets/skills/consulting-report/template/lib/types.ts +49 -1
  10. package/assets/skills/consulting-report/tools/generate-pdf.ts +99 -16
  11. package/assets/skills/entities/SKILL.md +95 -0
  12. package/assets/skills/telos/SKILL.md +1 -1
  13. package/assets/templates/PAL/ALGORITHM.md +1 -1
  14. package/assets/templates/PAL/README.md +2 -2
  15. package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +1 -1
  16. package/assets/templates/rules.codex.rules +64 -0
  17. package/assets/templates/settings.claude.json +2 -1
  18. package/package.json +11 -12
  19. package/src/cli/index.ts +8 -0
  20. package/src/cli/knowledge.ts +620 -0
  21. package/src/cli/migrate.ts +188 -3
  22. package/src/hooks/lib/claude-md.ts +6 -2
  23. package/src/hooks/lib/export.ts +1 -1
  24. package/src/hooks/lib/paths.ts +3 -1
  25. package/src/targets/codex/install.ts +14 -0
  26. package/src/targets/codex/uninstall.ts +14 -0
  27. package/src/targets/lib.ts +53 -36
  28. package/src/tools/knowledge/graph.ts +395 -0
  29. package/src/tools/knowledge/ingest.ts +409 -0
  30. package/src/tools/knowledge/lib.ts +493 -0
  31. package/assets/skills/extract-entities/SKILL.md +0 -62
  32. package/assets/skills/extract-entities/tools/entity-save.ts +0 -110
  33. package/src/hooks/lib/entities.ts +0 -304
  34. package/src/tools/export.ts +0 -40
  35. package/src/tools/import.ts +0 -111
@@ -10,9 +10,9 @@
10
10
  * pending work without running anything.
11
11
  */
12
12
 
13
- import { existsSync, readdirSync, readFileSync } from "node:fs";
13
+ import { existsSync, readdirSync, readFileSync, renameSync } from "node:fs";
14
14
  import { resolve } from "node:path";
15
- import { paths } from "../hooks/lib/paths";
15
+ import { palHome, paths } from "../hooks/lib/paths";
16
16
  import {
17
17
  legacyJsonToProgress,
18
18
  type ProjectProgress,
@@ -21,6 +21,14 @@ import {
21
21
  writeProject,
22
22
  } from "../hooks/lib/projects";
23
23
  import { readThreads, type Thread, writeThreads } from "../tools/agent/thread";
24
+ import { appendSourceLog } from "../tools/knowledge/ingest";
25
+ import {
26
+ type Entity,
27
+ type EntityFrontmatter,
28
+ exists as knowledgeExists,
29
+ save as knowledgeSave,
30
+ slugify,
31
+ } from "../tools/knowledge/lib";
24
32
 
25
33
  // ── Types ─────────────────────────────────────────────────────────
26
34
 
@@ -175,9 +183,186 @@ const v2ThreadsToIsc: Migration = {
175
183
  },
176
184
  };
177
185
 
186
+ // ── v3-entities-to-knowledge: entity-index.json → knowledge/*.md ──
187
+
188
+ interface LegacyPerson {
189
+ id: string;
190
+ name: string;
191
+ first_seen: string;
192
+ occurrences: number;
193
+ source_ids: string[];
194
+ }
195
+
196
+ interface LegacyCompany {
197
+ id: string;
198
+ name: string;
199
+ domain: string | null;
200
+ first_seen: string;
201
+ occurrences: number;
202
+ source_ids: string[];
203
+ }
204
+
205
+ interface LegacyIndex {
206
+ version?: string;
207
+ people?: Record<string, LegacyPerson>;
208
+ companies?: Record<string, LegacyCompany>;
209
+ links?: Record<string, unknown>;
210
+ sources?: Record<string, unknown>;
211
+ }
212
+
213
+ function legacyEntitiesPath(): string {
214
+ // Read from PAL_HOME-aware location to match where the legacy store lived;
215
+ // computed locally now that paths.entities() is being retired alongside this migration.
216
+ const home = palHome();
217
+ const dir = resolve(home, "memory", "entities");
218
+ return resolve(dir, "entity-index.json");
219
+ }
220
+
221
+ function readLegacyIndex(): LegacyIndex | null {
222
+ const p = legacyEntitiesPath();
223
+ if (!existsSync(p)) return null;
224
+ try {
225
+ return JSON.parse(readFileSync(p, "utf-8")) as LegacyIndex;
226
+ } catch {
227
+ return null;
228
+ }
229
+ }
230
+
231
+ function countLegacyEntries(idx: LegacyIndex): number {
232
+ return Object.keys(idx.people ?? {}).length + Object.keys(idx.companies ?? {}).length;
233
+ }
234
+
235
+ function legacyPersonToEntity(legacy: LegacyPerson): Entity {
236
+ const fm: EntityFrontmatter = {
237
+ title: legacy.name,
238
+ type: "person",
239
+ tags: [],
240
+ created: legacy.first_seen,
241
+ updated: legacy.first_seen,
242
+ quality: 5,
243
+ status: "seedling",
244
+ related: [],
245
+ legacy_id: legacy.id,
246
+ occurrences: legacy.occurrences,
247
+ };
248
+ let body = "";
249
+ for (const sourceId of legacy.source_ids) {
250
+ body = appendSourceLog(body, sourceId, null, {}, legacy.first_seen);
251
+ }
252
+ return { domain: "People", slug: slugify(legacy.name), frontmatter: fm, body };
253
+ }
254
+
255
+ function legacyCompanyToEntity(legacy: LegacyCompany): Entity {
256
+ const baseKey = legacy.domain?.trim() ? legacy.domain : legacy.name;
257
+ const fm: EntityFrontmatter = {
258
+ title: legacy.name,
259
+ type: "company",
260
+ tags: [],
261
+ created: legacy.first_seen,
262
+ updated: legacy.first_seen,
263
+ quality: 5,
264
+ status: "seedling",
265
+ related: [],
266
+ legacy_id: legacy.id,
267
+ occurrences: legacy.occurrences,
268
+ };
269
+ if (legacy.domain) fm.domain_name = legacy.domain;
270
+ let body = "";
271
+ for (const sourceId of legacy.source_ids) {
272
+ body = appendSourceLog(body, sourceId, null, {}, legacy.first_seen);
273
+ }
274
+ return { domain: "Companies", slug: slugify(baseKey), frontmatter: fm, body };
275
+ }
276
+
277
+ const v3EntitiesToKnowledge: Migration = {
278
+ id: "v3-entities-to-knowledge",
279
+ description: "Migrate legacy entity-index.json to knowledge/{People,Companies}/*.md",
280
+
281
+ check() {
282
+ const idx = readLegacyIndex();
283
+ if (!idx) return { pending: false };
284
+ const total = countLegacyEntries(idx);
285
+ if (total === 0) return { pending: false };
286
+ // Skip if every entity already exists in the new store (idempotent).
287
+ let remaining = 0;
288
+ for (const p of Object.values(idx.people ?? {})) {
289
+ if (!knowledgeExists("People", slugify(p.name))) remaining++;
290
+ }
291
+ for (const c of Object.values(idx.companies ?? {})) {
292
+ const key = c.domain?.trim() ? c.domain : c.name;
293
+ if (!knowledgeExists("Companies", slugify(key))) remaining++;
294
+ }
295
+ return {
296
+ pending: remaining > 0,
297
+ detail: remaining > 0 ? `${remaining} of ${total} entries to migrate` : undefined,
298
+ };
299
+ },
300
+
301
+ run(dryRun = false): MigrationResult {
302
+ const idx = readLegacyIndex();
303
+ if (!idx) return { migrated: 0, skipped: 0, results: [] };
304
+
305
+ let migrated = 0;
306
+ let skipped = 0;
307
+ const results: string[] = [];
308
+
309
+ // Refuse to silently drop links/sources if a future legacy index has them.
310
+ const linksCount = Object.keys(idx.links ?? {}).length;
311
+ const sourcesCount = Object.keys(idx.sources ?? {}).length;
312
+ if (linksCount > 0 || sourcesCount > 0) {
313
+ results.push(
314
+ `aborted: legacy index has ${linksCount} link(s) and ${sourcesCount} source(s) — no destination in new store`
315
+ );
316
+ return { migrated: 0, skipped: linksCount + sourcesCount, results };
317
+ }
318
+
319
+ for (const legacy of Object.values(idx.people ?? {})) {
320
+ const entity = legacyPersonToEntity(legacy);
321
+ if (knowledgeExists(entity.domain, entity.slug)) {
322
+ skipped++;
323
+ results.push(`People/${entity.slug}: skipped (already in new store)`);
324
+ continue;
325
+ }
326
+ if (!dryRun) knowledgeSave(entity);
327
+ migrated++;
328
+ results.push(`People/${entity.slug}: ${dryRun ? "would migrate" : "migrated"}`);
329
+ }
330
+
331
+ for (const legacy of Object.values(idx.companies ?? {})) {
332
+ const entity = legacyCompanyToEntity(legacy);
333
+ if (knowledgeExists(entity.domain, entity.slug)) {
334
+ skipped++;
335
+ results.push(`Companies/${entity.slug}: skipped (already in new store)`);
336
+ continue;
337
+ }
338
+ if (!dryRun) knowledgeSave(entity);
339
+ migrated++;
340
+ results.push(`Companies/${entity.slug}: ${dryRun ? "would migrate" : "migrated"}`);
341
+ }
342
+
343
+ // After a successful, non-dry-run migration, archive the legacy file so
344
+ // re-runs don't repeatedly load and skip its contents.
345
+ if (!dryRun && migrated > 0) {
346
+ const src = legacyEntitiesPath();
347
+ if (existsSync(src)) {
348
+ const date = new Date().toISOString().slice(0, 10);
349
+ const archived = `${src}.migrated-${date}`;
350
+ try {
351
+ renameSync(src, archived);
352
+ results.push(`archived legacy index → ${archived}`);
353
+ } catch (e) {
354
+ results.push(`warn: could not rename legacy index (${(e as Error).message})`);
355
+ }
356
+ }
357
+ }
358
+
359
+ return { migrated, skipped, results };
360
+ },
361
+ };
362
+
178
363
  // ── Registry ──────────────────────────────────────────────────────
179
364
 
180
- const MIGRATIONS: Migration[] = [v1Projects, v2ThreadsToIsc];
365
+ const MIGRATIONS: Migration[] = [v1Projects, v2ThreadsToIsc, v3EntitiesToKnowledge];
181
366
 
182
367
  // ── Public API ────────────────────────────────────────────────────
183
368
 
@@ -13,6 +13,7 @@ import {
13
13
  lstatSync,
14
14
  readdirSync,
15
15
  readFileSync,
16
+ readlinkSync,
16
17
  statSync,
17
18
  symlinkSync,
18
19
  unlinkSync,
@@ -54,8 +55,11 @@ function latestMtime(...filePaths: string[]): number {
54
55
  function ensureOneSymlink(linkPath: string, targetPath: string): void {
55
56
  try {
56
57
  const stat = lstatSync(linkPath);
57
- if (!stat.isSymbolicLink()) unlinkSync(linkPath);
58
- else return; // already a symlink, leave it
58
+ if (stat.isSymbolicLink()) {
59
+ const currentTarget = resolve(dirname(linkPath), readlinkSync(linkPath));
60
+ if (currentTarget === targetPath) return;
61
+ }
62
+ unlinkSync(linkPath);
59
63
  } catch {
60
64
  // doesn't exist — create it
61
65
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Shared export logic — zips user state directories.
3
- * Used by tools/export.ts (manual) and handlers/backup.ts (automatic).
3
+ * Used by cli/index.ts (pal cli export) and handlers/backup.ts (automatic).
4
4
  */
5
5
 
6
6
  import { existsSync, readdirSync } from "node:fs";
@@ -47,7 +47,8 @@ export const paths = {
47
47
  wisdom: () => ensureDir(home("memory", "wisdom", "frames")),
48
48
  wisdomState: () => ensureDir(home("memory", "wisdom", "state")),
49
49
  relationship: () => ensureDir(home("memory", "relationship")),
50
- entities: () => ensureDir(home("memory", "entities")),
50
+ knowledge: () => ensureDir(home("memory", "knowledge")),
51
+ knowledgeDomain: (d: string) => ensureDir(home("memory", "knowledge", d)),
51
52
  failures: () => ensureDir(home("memory", "learning", "failures")),
52
53
  retrievalIndex: () => home("memory", "learning", ".retrieval-index.json"),
53
54
  progress: () => ensureDir(home("memory", "state", "progress")),
@@ -80,6 +81,7 @@ export const assets = {
80
81
  cursorHooksTemplate: () => pkg("assets", "templates", "hooks.cursor.json"),
81
82
  copilotHooksTemplate: () => pkg("assets", "templates", "hooks.copilot.json"),
82
83
  codexHooksTemplate: () => pkg("assets", "templates", "hooks.codex.json"),
84
+ codexRulesTemplate: () => pkg("assets", "templates", "rules.codex.rules"),
83
85
  agentTools: () => pkg("src", "tools", "agent"),
84
86
  palDocs: () => pkg("assets", "templates", "PAL"),
85
87
  } as const;
@@ -19,8 +19,10 @@ import {
19
19
  countSkills,
20
20
  generateSkillIndex,
21
21
  loadCodexHooksTemplate,
22
+ loadCodexRulesTemplate,
22
23
  log,
23
24
  mergeCodexHooks,
25
+ mergeCodexRules,
24
26
  readJson,
25
27
  scaffoldPalSettings,
26
28
  writeJson,
@@ -56,6 +58,7 @@ function enableCodexHooks(configPath: string): void {
56
58
  const PKG_ROOT = palPkg().replaceAll("\\", "/");
57
59
  const CODEX_DIR = platform.codexDir();
58
60
  const HOOKS_FILE = resolve(CODEX_DIR, "hooks.json");
61
+ const RULES_FILE = resolve(CODEX_DIR, "rules", "default.rules");
59
62
 
60
63
  // --- Ensure ~/.codex/ exists ---
61
64
  mkdirSync(CODEX_DIR, { recursive: true });
@@ -73,6 +76,17 @@ const merged = mergeCodexHooks(existing, template);
73
76
  writeJson(HOOKS_FILE, merged);
74
77
  log.success("Merged PAL hooks into ~/.codex/hooks.json");
75
78
 
79
+ // --- Merge allowlist rules ---
80
+ mkdirSync(resolve(CODEX_DIR, "rules"), { recursive: true });
81
+ if (existsSync(RULES_FILE)) {
82
+ copyFileSync(RULES_FILE, `${RULES_FILE}.bak.${Date.now()}`);
83
+ log.info("Backed up rules/default.rules");
84
+ }
85
+ const rulesTemplate = loadCodexRulesTemplate(assets.codexRulesTemplate());
86
+ const existingRules = existsSync(RULES_FILE) ? readFileSync(RULES_FILE, "utf-8") : "";
87
+ writeFileSync(RULES_FILE, mergeCodexRules(existingRules, rulesTemplate), "utf-8");
88
+ log.success("Merged PAL allowlist rules into ~/.codex/rules/default.rules");
89
+
76
90
  // --- Symlink skills to ~/.codex/skills/ ---
77
91
  const codexSkillsDir = resolve(CODEX_DIR, "skills");
78
92
  copySkills(codexSkillsDir);
@@ -13,6 +13,7 @@ import {
13
13
  readJson,
14
14
  removeSkills,
15
15
  unmergeCodexHooks,
16
+ unmergeCodexRules,
16
17
  writeJson,
17
18
  } from "../lib";
18
19
 
@@ -38,6 +39,7 @@ function disableCodexHooks(configPath: string): void {
38
39
  const PKG_ROOT = palPkg().replaceAll("\\", "/");
39
40
  const CODEX_DIR = platform.codexDir();
40
41
  const HOOKS_FILE = resolve(CODEX_DIR, "hooks.json");
42
+ const RULES_FILE = resolve(CODEX_DIR, "rules", "default.rules");
41
43
 
42
44
  // --- Remove PAL hooks from hooks.json ---
43
45
  if (existsSync(HOOKS_FILE)) {
@@ -54,6 +56,18 @@ if (existsSync(HOOKS_FILE)) {
54
56
  log.info("No hooks.json found, nothing to do");
55
57
  }
56
58
 
59
+ // --- Remove PAL allowlist rules from default.rules ---
60
+ if (existsSync(RULES_FILE)) {
61
+ copyFileSync(RULES_FILE, `${RULES_FILE}.bak.${Date.now()}`);
62
+ log.info("Backed up rules/default.rules");
63
+
64
+ const cleanedRules = unmergeCodexRules(readFileSync(RULES_FILE, "utf-8"));
65
+ writeFileSync(RULES_FILE, cleanedRules, "utf-8");
66
+ log.success("Removed PAL allowlist rules from ~/.codex/rules/default.rules");
67
+ } else {
68
+ log.info("No default.rules found, nothing to do");
69
+ }
70
+
57
71
  // --- Remove PAL skill symlinks ---
58
72
  const codexSkillsDir = resolve(CODEX_DIR, "skills");
59
73
  const removed = removeSkills(codexSkillsDir);
@@ -273,22 +273,22 @@ export function loadCodexHooksTemplate(
273
273
  }
274
274
  }
275
275
 
276
- /** Merge PAL hooks into an existing Codex hooks.json. Deduplicates by canonical command path. */
277
- export function mergeCodexHooks(existing: CodexHooks, template: CodexHooks): CodexHooks {
278
- const result: CodexHooks = { ...existing };
279
- if (!template.hooks) return result;
280
- result.hooks ??= {};
281
-
282
- // Collect canonical paths of PAL template commands so we can evict stale variants
283
- const palCanonical = new Set(
284
- Object.values(template.hooks).flatMap((groups) =>
276
+ /** Collect canonical command paths from a Codex hooks template (PAL-managed commands). */
277
+ function collectPalCanonical(template: CodexHooks): Set<string> {
278
+ return new Set(
279
+ Object.values(template.hooks ?? {}).flatMap((groups) =>
285
280
  groups.flatMap((g) => g.hooks.map((h) => canonicalCmd(h.command)))
286
281
  )
287
282
  );
283
+ }
288
284
 
289
- // Strip any existing entries (nested or flat) whose canonical path matches a PAL command
290
- for (const event of Object.keys(result.hooks)) {
291
- result.hooks[event] = (result.hooks[event] ?? [])
285
+ /** Strip entries (nested or flat) whose canonical command matches a PAL-managed command. */
286
+ function stripPalHooks(
287
+ hooks: Record<string, CodexHookGroup[]>,
288
+ palCanonical: Set<string>
289
+ ): void {
290
+ for (const event of Object.keys(hooks)) {
291
+ hooks[event] = (hooks[event] ?? [])
292
292
  .map((g) => {
293
293
  const flat = g as unknown as CodexHookCommand;
294
294
  if (!g.hooks && flat.command && palCanonical.has(canonicalCmd(flat.command))) {
@@ -300,10 +300,18 @@ export function mergeCodexHooks(existing: CodexHooks, template: CodexHooks): Cod
300
300
  return filtered.length > 0 ? { ...g, hooks: filtered } : null;
301
301
  })
302
302
  .filter((g): g is CodexHookGroup => g !== null);
303
- if (result.hooks[event].length === 0) delete result.hooks[event];
303
+ if (hooks[event].length === 0) delete hooks[event];
304
304
  }
305
+ }
306
+
307
+ /** Merge PAL hooks into an existing Codex hooks.json. Deduplicates by canonical command path. */
308
+ export function mergeCodexHooks(existing: CodexHooks, template: CodexHooks): CodexHooks {
309
+ const result: CodexHooks = { ...existing };
310
+ if (!template.hooks) return result;
311
+ result.hooks ??= {};
312
+
313
+ stripPalHooks(result.hooks, collectPalCanonical(template));
305
314
 
306
- // Add fresh template entries
307
315
  for (const [event, groups] of Object.entries(template.hooks)) {
308
316
  const current = result.hooks[event] ?? [];
309
317
  for (const group of groups) current.push(group);
@@ -320,32 +328,41 @@ export function unmergeCodexHooks(
320
328
  const result: CodexHooks = { ...existing };
321
329
  if (!template.hooks || !result.hooks) return result;
322
330
 
323
- // Match by canonical path so prefix variants (PAL_AGENT=codex, etc.) are all removed
324
- const palCanonical = new Set(
325
- Object.values(template.hooks).flatMap((groups) =>
326
- groups.flatMap((g) => g.hooks.map((h) => canonicalCmd(h.command)))
327
- )
328
- );
329
-
330
- for (const event of Object.keys(result.hooks)) {
331
- result.hooks[event] = (result.hooks[event] ?? [])
332
- .map((g) => {
333
- const flat = g as unknown as CodexHookCommand;
334
- if (!g.hooks && flat.command && palCanonical.has(canonicalCmd(flat.command))) {
335
- return null;
336
- }
337
- const filtered = (g.hooks ?? []).filter(
338
- (h) => !palCanonical.has(canonicalCmd(h.command))
339
- );
340
- return filtered.length > 0 ? { ...g, hooks: filtered } : null;
341
- })
342
- .filter((g): g is CodexHookGroup => g !== null);
343
- if (result.hooks[event].length === 0) delete result.hooks[event];
344
- }
331
+ stripPalHooks(result.hooks, collectPalCanonical(template));
345
332
  if (Object.keys(result.hooks).length === 0) delete result.hooks;
346
333
  return result;
347
334
  }
348
335
 
336
+ // --- Codex rules (Starlark .rules file) ---
337
+
338
+ const CODEX_RULES_BEGIN = "# BEGIN PAL MANAGED CODEX RULES";
339
+ const CODEX_RULES_END = "# END PAL MANAGED CODEX RULES";
340
+
341
+ export function loadCodexRulesTemplate(templatePath: string): string {
342
+ return readFileSync(templatePath, "utf-8").trim();
343
+ }
344
+
345
+ function stripPalCodexRules(content: string): string {
346
+ const escapedBegin = CODEX_RULES_BEGIN.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
347
+ const escapedEnd = CODEX_RULES_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
348
+ const block = new RegExp(String.raw`\n?${escapedBegin}[\s\S]*?${escapedEnd}\n?`, "g");
349
+ return content
350
+ .replace(block, "\n")
351
+ .replace(/\n{3,}/g, "\n\n")
352
+ .trim();
353
+ }
354
+
355
+ export function mergeCodexRules(existing: string, template: string): string {
356
+ const preserved = stripPalCodexRules(existing);
357
+ const prefix = preserved ? `${preserved}\n\n` : "";
358
+ return `${prefix}${template.trim()}\n`;
359
+ }
360
+
361
+ export function unmergeCodexRules(existing: string): string {
362
+ const cleaned = stripPalCodexRules(existing);
363
+ return cleaned ? `${cleaned}\n` : "";
364
+ }
365
+
349
366
  // --- TELOS scaffolding ---
350
367
 
351
368
  /** Copy template files into telos/ without overwriting existing ones */