portable-agent-layer 0.63.3 → 0.65.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 (65) hide show
  1. package/README.md +8 -4
  2. package/assets/schema/pal-settings.schema.json +4 -0
  3. package/assets/skills/analyze-pdf/SKILL.md +11 -0
  4. package/assets/skills/analyze-youtube/SKILL.md +12 -0
  5. package/assets/skills/consulting-report/SKILL.md +9 -0
  6. package/assets/skills/consulting-report/tools/generate-pdf.mjs +2 -2
  7. package/assets/skills/consulting-report/tools/generate-pdf.ts +5 -2
  8. package/assets/skills/council/SKILL.md +32 -0
  9. package/assets/skills/create-pdf/SKILL.md +13 -0
  10. package/assets/skills/create-skill/SKILL.md +14 -2
  11. package/assets/skills/create-skill/authoring-guide.md +10 -1
  12. package/assets/skills/create-subagent/SKILL.md +22 -4
  13. package/assets/skills/{research → deep-research}/SKILL.md +32 -1
  14. package/assets/skills/entities/SKILL.md +10 -0
  15. package/assets/skills/extract-wisdom/SKILL.md +12 -0
  16. package/assets/skills/first-principles/SKILL.md +8 -0
  17. package/assets/skills/frontend-design/SKILL.md +14 -0
  18. package/assets/skills/fyzz-chat-api/SKILL.md +10 -0
  19. package/assets/skills/humanize/SKILL.md +13 -1
  20. package/assets/skills/opinion/SKILL.md +11 -0
  21. package/assets/skills/pal-analyze/SKILL.md +11 -0
  22. package/assets/skills/pal-reflect/SKILL.md +10 -0
  23. package/assets/skills/playwright/SKILL.md +15 -2
  24. package/assets/skills/playwright/tools/shot.ts +6 -7
  25. package/assets/skills/presentation/SKILL.md +12 -0
  26. package/assets/skills/projects/SKILL.md +20 -1
  27. package/assets/skills/reflect/SKILL.md +13 -0
  28. package/assets/skills/telos/SKILL.md +12 -0
  29. package/assets/skills/think/SKILL.md +9 -0
  30. package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +3 -0
  31. package/assets/templates/pal-settings.json +1 -0
  32. package/assets/templates/settings.claude.json +2 -1
  33. package/package.json +15 -4
  34. package/src/cli/index.ts +95 -9
  35. package/src/cli/migrate.ts +69 -3
  36. package/src/cli/skill.ts +47 -3
  37. package/src/hooks/handlers/inject-retrieval.ts +20 -10
  38. package/src/hooks/lib/anchor.ts +90 -0
  39. package/src/hooks/lib/bindings.ts +117 -0
  40. package/src/hooks/lib/export.ts +38 -1
  41. package/src/hooks/lib/import-merge.ts +220 -0
  42. package/src/hooks/lib/inference.ts +113 -72
  43. package/src/hooks/lib/machine.ts +176 -0
  44. package/src/hooks/lib/projects.ts +223 -15
  45. package/src/hooks/lib/readme-sync.ts +30 -10
  46. package/src/hooks/lib/relationship.ts +3 -1
  47. package/src/hooks/lib/remote.ts +58 -0
  48. package/src/hooks/lib/retrieval.ts +8 -2
  49. package/src/hooks/lib/signals.ts +2 -1
  50. package/src/hooks/lib/skill-match.ts +129 -0
  51. package/src/hooks/lib/skill-triggers.ts +82 -0
  52. package/src/hooks/lib/stop.ts +5 -2
  53. package/src/targets/lib.ts +137 -35
  54. package/src/targets/opencode/plugin.ts +2 -6
  55. package/src/tools/agent/algorithm-reflect.ts +45 -11
  56. package/src/tools/agent/project.ts +148 -23
  57. package/src/tools/agent/thread.ts +7 -2
  58. package/src/tools/skill-doctor.ts +130 -5
  59. package/assets/skills/playwright/tools/shot-lib.mjs +0 -44
  60. package/assets/skills/playwright/tools/shot.mjs +0 -89
  61. package/assets/skills/review/SKILL.md +0 -20
  62. package/assets/skills/summarize/SKILL.md +0 -16
  63. /package/assets/skills/{research → deep-research}/tools/gemini-search.ts +0 -0
  64. /package/assets/skills/{research → deep-research}/tools/grok-search.ts +0 -0
  65. /package/assets/skills/{research → deep-research}/tools/perplexity-search.ts +0 -0
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Path anchors — `{proj:slug}/relative` instead of an absolute path.
3
+ *
4
+ * An absolute cwd stamp never matches across machines: same project,
5
+ * different mount, different username, different OS. An anchor replaces the
6
+ * absolute prefix with the project's registry slug, so resolution happens
7
+ * locally at read time — the same trick machine.ts uses for labels, applied
8
+ * to paths. Relocating a project (`project.ts set-path`) then fixes every
9
+ * memory that ever referenced it, because none of them stored the path.
10
+ */
11
+
12
+ import { relative, resolve, sep } from "node:path";
13
+ import type { ProjectProgress } from "./projects";
14
+ import {
15
+ projectPathOnThisMachine,
16
+ readAllProjects,
17
+ resolveProjectFromCwd,
18
+ } from "./projects";
19
+
20
+ const ANCHOR_RE = /^\{proj:([a-z0-9_-]+)\}(\/.*)?$/;
21
+
22
+ export function isAnchor(value: string): boolean {
23
+ return ANCHOR_RE.test(value);
24
+ }
25
+
26
+ /**
27
+ * Absolute path → `{proj:slug}/relative`, if it falls inside a registered
28
+ * project. A path outside every registered project passes through
29
+ * unchanged — most cwd stamps from ad hoc commands will never resolve to a
30
+ * project, and that is fine; they simply do not benefit yet.
31
+ */
32
+ export function encodeAnchor(
33
+ absPath: string,
34
+ projects: ProjectProgress[] = readAllProjects()
35
+ ): string {
36
+ const proj = resolveProjectFromCwd(absPath, projects);
37
+ if (!proj) return absPath;
38
+
39
+ // The same effective path the resolver matched on, not the record's own field:
40
+ // a bound project's path lives in bindings, so anchoring off proj.path directly
41
+ // would measure the relative segment against the wrong root.
42
+ const root = projectPathOnThisMachine(proj);
43
+ if (!root) return absPath;
44
+
45
+ const rel = relative(root, resolve(absPath));
46
+ if (rel.startsWith("..")) return absPath;
47
+
48
+ const relPosix = rel.split(sep).join("/");
49
+ return relPosix ? `{proj:${proj.name}}/${relPosix}` : `{proj:${proj.name}}`;
50
+ }
51
+
52
+ export type AnchorResolution =
53
+ | { state: "anchored"; path: string }
54
+ | { state: "plain"; path: string }
55
+ | { state: "unresolvable"; slug: string };
56
+
57
+ /**
58
+ * `{proj:slug}/relative` → absolute path on THIS machine, via the local
59
+ * registry. A plain (non-anchor) value is returned as-is — either an
60
+ * already-local absolute path, or a record captured before this feature
61
+ * shipped. There is no backfill, so pre-anchor records are handled exactly
62
+ * as they were before.
63
+ */
64
+ export function resolveAnchor(
65
+ value: string,
66
+ projects: ProjectProgress[] = readAllProjects()
67
+ ): AnchorResolution {
68
+ const match = ANCHOR_RE.exec(value);
69
+ if (!match) return { state: "plain", path: value };
70
+
71
+ const [, slug, rel] = match;
72
+ const proj = projects.find((p) => p.name === slug);
73
+ if (!proj) return { state: "unresolvable", slug };
74
+
75
+ const root = projectPathOnThisMachine(proj);
76
+ if (!root) return { state: "unresolvable", slug };
77
+ const path = rel ? resolve(root, `.${rel}`) : root;
78
+ return { state: "anchored", path };
79
+ }
80
+
81
+ /** Does `value` (anchored or plain) refer to `cwd` on this machine? */
82
+ export function anchorMatchesCwd(
83
+ value: string,
84
+ cwd: string,
85
+ projects: ProjectProgress[] = readAllProjects()
86
+ ): boolean {
87
+ const resolved = resolveAnchor(value, projects);
88
+ if (resolved.state === "unresolvable") return false;
89
+ return resolve(resolved.path) === resolve(cwd);
90
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Bindings — where each project lives on THIS machine.
3
+ *
4
+ * A project record answers two questions in one field today: what the project
5
+ * is (portable — name, goal, criteria, decisions) and where it sits on disk
6
+ * (true on exactly one machine). The second answer rides inside `memory/`,
7
+ * which is exported, so one machine's filesystem layout travels to every other
8
+ * machine as though it were a fact about the project.
9
+ *
10
+ * This module holds the second answer separately. Memory becomes the union of
11
+ * all work; bindings are one machine's intersection with its disk, so "that
12
+ * project is not checked out here" turns into an ordinary state rather than a
13
+ * dead path.
14
+ *
15
+ * `bindings.json` lives at the PAL_HOME root for the same reason `machine.json`
16
+ * does: export walks `telos`, `memory`, `skills` and `agents`, so anything
17
+ * under those would sync and defeat the point.
18
+ */
19
+
20
+ import { copyFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { resolve } from "node:path";
23
+ import { palHome } from "./paths";
24
+
25
+ /** Project name → absolute path on this machine. */
26
+ export type Bindings = Record<string, string>;
27
+
28
+ export function bindingsFilePath(home: string = palHome()): string {
29
+ return resolve(home, "bindings.json");
30
+ }
31
+
32
+ /**
33
+ * Where the previous bindings are kept.
34
+ *
35
+ * Once a record stops storing its own path, this file is the only place a
36
+ * project's location lives, and it is deliberately excluded from exports — so
37
+ * losing it loses every location. One rolling copy of the last good content
38
+ * makes that recoverable by renaming a file, and never goes stale the way a
39
+ * one-off backup taken at migration time would.
40
+ */
41
+ export function bindingsBackupPath(home: string = palHome()): string {
42
+ return resolve(home, "bindings.backup.json");
43
+ }
44
+
45
+ function isBindingMap(value: unknown): value is Bindings {
46
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
47
+ return Object.values(value).every((v) => typeof v === "string");
48
+ }
49
+
50
+ /**
51
+ * A malformed or unreadable file reads as empty rather than throwing. Bindings
52
+ * are a lookup aid, so a corrupt one degrades to "nothing is bound here" — the
53
+ * same state a fresh machine starts in — instead of breaking every caller.
54
+ */
55
+ export function readBindings(home: string = palHome()): Bindings {
56
+ const file = bindingsFilePath(home);
57
+ if (!existsSync(file)) return {};
58
+ try {
59
+ const parsed: unknown = JSON.parse(readFileSync(file, "utf-8"));
60
+ if (!isBindingMap(parsed)) return {};
61
+ return parsed;
62
+ } catch {
63
+ return {};
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Seeding writes on the first project read, which makes an unsandboxed test a
69
+ * silent writer into the developer's own ~/.pal. The suite sets PAL_TEST_SANDBOX,
70
+ * so refuse there and name the file — a test that forgets to point PAL_HOME at a
71
+ * temp dir fails loudly instead of editing the machine running it.
72
+ */
73
+ function assertNotRealHomeDuringTests(home: string): void {
74
+ if (!process.env.PAL_TEST_SANDBOX) return;
75
+ if (resolve(home) !== resolve(homedir(), ".pal")) return;
76
+ throw new Error(
77
+ "Refusing to write bindings.json into the real ~/.pal during a test run. " +
78
+ "Point PAL_HOME at a temp directory in this test's setup."
79
+ );
80
+ }
81
+
82
+ export function writeBindings(bindings: Bindings, home: string = palHome()): void {
83
+ assertNotRealHomeDuringTests(home);
84
+ const file = bindingsFilePath(home);
85
+ // Only a differing, non-empty predecessor is worth keeping: backing up an
86
+ // identical file is noise, and backing up an empty one would let a bad write
87
+ // erase the copy that made it recoverable.
88
+ if (existsSync(file) && readFileSync(file, "utf-8").trim().length > 0) {
89
+ copyFileSync(file, bindingsBackupPath(home));
90
+ }
91
+ const sorted: Bindings = {};
92
+ for (const key of Object.keys(bindings).sort()) sorted[key] = bindings[key];
93
+ writeFileSync(bindingsFilePath(home), `${JSON.stringify(sorted, null, 2)}\n`);
94
+ }
95
+
96
+ /** The absolute path this machine has for `project`, or null when unbound. */
97
+ export function bindingFor(project: string, home: string = palHome()): string | null {
98
+ return readBindings(home)[project] ?? null;
99
+ }
100
+
101
+ /** Bind `project` to `path`, replacing any existing binding for it. */
102
+ export function writeBinding(
103
+ project: string,
104
+ path: string,
105
+ home: string = palHome()
106
+ ): void {
107
+ const bindings = readBindings(home);
108
+ bindings[project] = resolve(path);
109
+ writeBindings(bindings, home);
110
+ }
111
+
112
+ export function removeBinding(project: string, home: string = palHome()): void {
113
+ const bindings = readBindings(home);
114
+ if (!(project in bindings)) return;
115
+ delete bindings[project];
116
+ writeBindings(bindings, home);
117
+ }
@@ -6,6 +6,7 @@
6
6
  import { existsSync, readdirSync } from "node:fs";
7
7
  import { relative, resolve } from "node:path";
8
8
  import AdmZip from "adm-zip";
9
+ import { ensureRegistered } from "./machine";
9
10
  import { palHome } from "./paths";
10
11
 
11
12
  /**
@@ -59,9 +60,41 @@ export function collectExportFiles(): string[] {
59
60
  return files;
60
61
  }
61
62
 
62
- /** Zip the given files and write to outputPath. Returns file count. */
63
+ /** Archive metadata naming the machine that produced it. */
64
+ export const MANIFEST_NAME = "export-manifest.json";
65
+
66
+ export interface ExportManifest {
67
+ machineId: string;
68
+ label: string;
69
+ os: string;
70
+ exportedAt: string;
71
+ fileCount: number;
72
+ }
73
+
74
+ export function buildManifest(
75
+ identity: { id: string; label: string; os: string },
76
+ fileCount: number
77
+ ): ExportManifest {
78
+ return {
79
+ machineId: identity.id,
80
+ label: identity.label,
81
+ os: identity.os,
82
+ exportedAt: new Date().toISOString(),
83
+ fileCount,
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Zip the given files and write to outputPath. Returns file count.
89
+ *
90
+ * The archive declares its source machine in a manifest. Registry entries under
91
+ * memory/machines/ travel with the corpus, so the manifest exists to say which
92
+ * machine produced THIS archive — after a merge an archive can carry entries
93
+ * for several machines.
94
+ */
63
95
  export function exportZip(outputPath: string): number {
64
96
  const root = palHome();
97
+ const identity = ensureRegistered(root);
65
98
  const files = collectExportFiles();
66
99
  if (files.length === 0) return 0;
67
100
 
@@ -71,6 +104,10 @@ export function exportZip(outputPath: string): number {
71
104
  const dir = file.includes("/") ? file.slice(0, file.lastIndexOf("/")) : "";
72
105
  zip.addLocalFile(fullPath, dir);
73
106
  }
107
+ zip.addFile(
108
+ MANIFEST_NAME,
109
+ Buffer.from(`${JSON.stringify(buildManifest(identity, files.length), null, 2)}\n`)
110
+ );
74
111
 
75
112
  zip.writeZip(outputPath);
76
113
  return files.length;
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Import merge — fold an export archive into an existing PAL home without
3
+ * destroying local records.
4
+ *
5
+ * `extractAllTo(home, true)` overwrites every colliding path, so importing
6
+ * machine A onto machine B silently discards B's side of every append-only log.
7
+ * This module replaces that with a per-type policy:
8
+ *
9
+ * *.jsonl union of both sides, deduplicated by exact line
10
+ * new files written as-is
11
+ * identical no-op
12
+ * diverged local kept in place, incoming quarantined under backups/
13
+ * denylisted never written (machine identity, rebuildable indexes)
14
+ *
15
+ * Every policy is idempotent: re-importing the same archive is a no-op on the
16
+ * corpus.
17
+ */
18
+
19
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
+ import { dirname, resolve } from "node:path";
21
+ import { type ExportManifest, MANIFEST_NAME } from "./export";
22
+
23
+ /** One file inside an export archive, decoupled from the zip library. */
24
+ export interface ArchiveEntry {
25
+ path: string;
26
+ data(): Buffer;
27
+ }
28
+
29
+ export interface MergeResult {
30
+ created: string[];
31
+ merged: string[];
32
+ identical: string[];
33
+ conflicts: string[];
34
+ skipped: string[];
35
+ linesAdded: number;
36
+ quarantineDir: string | null;
37
+ }
38
+
39
+ /**
40
+ * Paths that must never cross machines. `machine.json` carries this install's
41
+ * identity — importing it would give two machines one id and silently break
42
+ * every origin-scoped read. The retrieval index is rebuilt from its sources.
43
+ */
44
+ const NEVER_IMPORT = [
45
+ "machine.json",
46
+ "export-manifest.json",
47
+ "memory/learning/.retrieval-index.json",
48
+ ];
49
+
50
+ function normalize(path: string): string {
51
+ return path.replaceAll("\\", "/").replace(/^\.\//, "");
52
+ }
53
+
54
+ export function isNeverImport(path: string): boolean {
55
+ const rel = normalize(path);
56
+ return NEVER_IMPORT.some((deny) => rel === deny || rel.endsWith(`/${deny}`));
57
+ }
58
+
59
+ function isJsonl(path: string): boolean {
60
+ return normalize(path).endsWith(".jsonl");
61
+ }
62
+
63
+ function splitLines(raw: string): string[] {
64
+ return raw.split("\n").filter((l) => l.trim().length > 0);
65
+ }
66
+
67
+ /**
68
+ * Union of two JSONL bodies, local order preserved, incoming lines appended
69
+ * only when not already present. Exact-line identity is the dedupe key — no
70
+ * schema is shared across PAL's jsonl files, and every writer serializes a
71
+ * record the same way, so byte equality is the only key that holds for all of
72
+ * them.
73
+ */
74
+ export function mergeJsonlLines(
75
+ localRaw: string,
76
+ incomingRaw: string
77
+ ): { text: string; added: number } {
78
+ const local = splitLines(localRaw);
79
+ const seen = new Set(local);
80
+ const added: string[] = [];
81
+ for (const line of splitLines(incomingRaw)) {
82
+ if (seen.has(line)) continue;
83
+ seen.add(line);
84
+ added.push(line);
85
+ }
86
+ const all = [...local, ...added];
87
+ return { text: all.length > 0 ? `${all.join("\n")}\n` : "", added: added.length };
88
+ }
89
+
90
+ function writeFileEnsuringDir(target: string, data: Buffer | string): void {
91
+ mkdirSync(dirname(target), { recursive: true });
92
+ writeFileSync(target, data);
93
+ }
94
+
95
+ function quarantine(quarantineDir: string, rel: string, data: Buffer): void {
96
+ writeFileEnsuringDir(resolve(quarantineDir, rel), data);
97
+ }
98
+
99
+ /**
100
+ * Merge every archive entry into `home`. `quarantineDir` receives the incoming
101
+ * copy of any file that diverged from its local counterpart, so a conflict
102
+ * loses neither side.
103
+ */
104
+ export function mergeArchive(
105
+ entries: ArchiveEntry[],
106
+ home: string,
107
+ quarantineDir: string
108
+ ): MergeResult {
109
+ const result: MergeResult = {
110
+ created: [],
111
+ merged: [],
112
+ identical: [],
113
+ conflicts: [],
114
+ skipped: [],
115
+ linesAdded: 0,
116
+ quarantineDir: null,
117
+ };
118
+
119
+ for (const entry of entries) {
120
+ const rel = normalize(entry.path);
121
+ if (rel.length === 0 || rel.endsWith("/")) continue;
122
+
123
+ if (isNeverImport(rel)) {
124
+ result.skipped.push(rel);
125
+ continue;
126
+ }
127
+
128
+ const target = resolve(home, rel);
129
+ const incoming = entry.data();
130
+
131
+ if (!existsSync(target)) {
132
+ writeFileEnsuringDir(target, incoming);
133
+ result.created.push(rel);
134
+ continue;
135
+ }
136
+
137
+ const localRaw = readFileSync(target);
138
+ if (localRaw.equals(incoming)) {
139
+ result.identical.push(rel);
140
+ continue;
141
+ }
142
+
143
+ if (isJsonl(rel)) {
144
+ const { text, added } = mergeJsonlLines(
145
+ localRaw.toString("utf-8"),
146
+ incoming.toString("utf-8")
147
+ );
148
+ writeFileSync(target, text);
149
+ result.merged.push(rel);
150
+ result.linesAdded += added;
151
+ continue;
152
+ }
153
+
154
+ quarantine(quarantineDir, rel, incoming);
155
+ result.conflicts.push(rel);
156
+ result.quarantineDir = quarantineDir;
157
+ }
158
+
159
+ return result;
160
+ }
161
+
162
+ /**
163
+ * The source machine declared by an archive's manifest, or null when the
164
+ * archive predates manifests.
165
+ */
166
+ export function readManifest(entries: ArchiveEntry[]): ExportManifest | null {
167
+ const hit = entries.find((e) => normalize(e.path) === MANIFEST_NAME);
168
+ if (!hit) return null;
169
+ try {
170
+ const parsed = JSON.parse(hit.data().toString("utf-8")) as Partial<ExportManifest>;
171
+ if (typeof parsed.machineId !== "string" || parsed.machineId.length === 0)
172
+ return null;
173
+ return {
174
+ machineId: parsed.machineId,
175
+ label: parsed.label ?? parsed.machineId,
176
+ os: parsed.os ?? "",
177
+ exportedAt: parsed.exportedAt ?? "",
178
+ fileCount: parsed.fileCount ?? 0,
179
+ };
180
+ } catch {
181
+ return null;
182
+ }
183
+ }
184
+
185
+ export interface ImportLogEntry {
186
+ ts: string;
187
+ archive: string;
188
+ mode: "merge" | "overwrite";
189
+ created: number;
190
+ merged: number;
191
+ identical: number;
192
+ conflicts: number;
193
+ skipped: number;
194
+ linesAdded: number;
195
+ quarantineDir: string | null;
196
+ sourceMachineId?: string | null;
197
+ }
198
+
199
+ /** Append one record per import so a merged corpus stays attributable. */
200
+ export function appendImportLog(home: string, entry: ImportLogEntry): void {
201
+ const logPath = resolve(home, "memory", "state", "import-log.jsonl");
202
+ mkdirSync(dirname(logPath), { recursive: true });
203
+ const line = `${JSON.stringify(entry)}\n`;
204
+ if (existsSync(logPath)) {
205
+ writeFileSync(logPath, readFileSync(logPath, "utf-8") + line);
206
+ return;
207
+ }
208
+ writeFileSync(logPath, line);
209
+ }
210
+
211
+ export function summarize(result: MergeResult): string {
212
+ const parts = [
213
+ `${result.created.length} new`,
214
+ `${result.merged.length} merged (+${result.linesAdded} records)`,
215
+ `${result.identical.length} unchanged`,
216
+ ];
217
+ if (result.conflicts.length > 0) parts.push(`${result.conflicts.length} conflicts`);
218
+ if (result.skipped.length > 0) parts.push(`${result.skipped.length} skipped`);
219
+ return parts.join(", ");
220
+ }