Mike0021 commited on
Commit
314746a
·
verified ·
1 Parent(s): 1caa3bf

Only hf-cli on by default; the user's skill choices kept across machines

Browse files
Dockerfile CHANGED
@@ -23,6 +23,10 @@ RUN UV_TOOL_DIR=/opt/uv-tools UV_TOOL_BIN_DIR=/usr/local/bin uv tool install --p
23
  && for skill in $(hf skills list -q 2>/dev/null | grep -vx 'hf-cli' | grep -Ev "${HF_SKILLS_EXCLUDE}"); do hf skills add "$skill" --global --force; done \
24
  && rm -rf /root/.claude /root/.cache/huggingface \
25
  && ls /root/.agents/skills
 
 
 
 
26
 
27
  # A real Chromium for Pi Web's Browser pane and the agent's browser_* tools (lib/browser): the
28
  # revision playwright-core 1.63.0 drives (Pi Web's own dependency), with the system libraries
 
23
  && for skill in $(hf skills list -q 2>/dev/null | grep -vx 'hf-cli' | grep -Ev "${HF_SKILLS_EXCLUDE}"); do hf skills add "$skill" --global --force; done \
24
  && rm -rf /root/.claude /root/.cache/huggingface \
25
  && ls /root/.agents/skills
26
+ # Only hf-cli is on by default; the other skills stay installed but off (sandbox/skill-defaults.py).
27
+ ARG HF_SKILLS_ENABLED="hf-cli"
28
+ COPY sandbox/skill-defaults.py /tmp/skill-defaults.py
29
+ RUN python3 /tmp/skill-defaults.py /root/.agents/skills ${HF_SKILLS_ENABLED} && rm /tmp/skill-defaults.py
30
 
31
  # A real Chromium for Pi Web's Browser pane and the agent's browser_* tools (lib/browser): the
32
  # revision playwright-core 1.63.0 drives (Pi Web's own dependency), with the system libraries
piweb/hf-design.patch CHANGED
@@ -468,6 +468,27 @@ index 0000000..0e30af4
468
  +export const PATCH = handle;
469
  +export const DELETE = handle;
470
  +export const OPTIONS = handle;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
471
  diff --git a/app/api/terminal/[id]/events/route.ts b/app/api/terminal/[id]/events/route.ts
472
  index 92511c1..baeec2f 100644
473
  --- a/app/api/terminal/[id]/events/route.ts
@@ -32411,6 +32432,25 @@ index 38b6356..0000000
32411
  - }, [enabled, ready]);
32412
  - return ready;
32413
  -}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32414
  diff --git a/lib/browser/event-channel.test.mjs b/lib/browser/event-channel.test.mjs
32415
  new file mode 100644
32416
  index 0000000..90b7506
@@ -39559,6 +39599,190 @@ index 0000000..2f7cd39
39559
  + const ids: ShortcutId[] = ["terminal", "changes", "browser", "files", "expandPane", ...(mac ? ["closePane" as const] : [])];
39560
  + return ids.some((id) => isShortcut(event, id, mac));
39561
  +}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39562
  diff --git a/lib/step-summary.test.mjs b/lib/step-summary.test.mjs
39563
  new file mode 100644
39564
  index 0000000..6cbe005
 
468
  +export const PATCH = handle;
469
  +export const DELETE = handle;
470
  +export const OPTIONS = handle;
471
+ diff --git a/app/api/skills/route.ts b/app/api/skills/route.ts
472
+ index a503398..2feacc5 100644
473
+ --- a/app/api/skills/route.ts
474
+ +++ b/app/api/skills/route.ts
475
+ @@ -6,6 +6,7 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
476
+ import type { SkillToggleResult } from "@/lib/api-types";
477
+ import { loadSkillsWithInstallInfo } from "@/lib/skills-service";
478
+ import { setDisableModelInvocation } from "@/lib/skill-frontmatter";
479
+ +import { recordSkillChoice } from "@/lib/skill-choices";
480
+ import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
481
+
482
+ export const dynamic = "force-dynamic";
483
+ @@ -58,6 +59,8 @@ function toggleSkillFile(
484
+ const updated = setDisableModelInvocation(content, disableModelInvocation);
485
+ // A skill already in the requested state keeps its file and mtime.
486
+ if (updated !== content) writeFileSync(filePath, updated, "utf8");
487
+ + // An image's skill comes back with the image's default on a new machine: keep the choice.
488
+ + recordSkillChoice(filePath, !disableModelInvocation);
489
+ return null;
490
+ }
491
+
492
  diff --git a/app/api/terminal/[id]/events/route.ts b/app/api/terminal/[id]/events/route.ts
493
  index 92511c1..baeec2f 100644
494
  --- a/app/api/terminal/[id]/events/route.ts
 
32432
  - }, [enabled, ready]);
32433
  - return ready;
32434
  -}
32435
+ diff --git a/instrumentation-node.ts b/instrumentation-node.ts
32436
+ index cb2f025..2abf76e 100644
32437
+ --- a/instrumentation-node.ts
32438
+ +++ b/instrumentation-node.ts
32439
+ @@ -21,4 +21,14 @@ export function registerNodeInstrumentation(): void {
32440
+ // it overlaps the browser opening, and the routes reuse Node's cached module.
32441
+ // A failure is left for the routes' own import to report.
32442
+ import("@earendil-works/pi-coding-agent").catch(() => {});
32443
+ +
32444
+ + // The skills a machine image installs come back with the image's defaults on a
32445
+ + // new machine: put the user's own choices back before any session loads them.
32446
+ + import("@/lib/skill-choices")
32447
+ + .then(({ applySkillChoices }) => {
32448
+ + const { applied, failed } = applySkillChoices();
32449
+ + if (applied.length) console.log(`[skills] kept choices applied: ${applied.join(", ")}`);
32450
+ + if (failed.length) console.warn(`[skills] could not apply the kept choice for: ${failed.join(", ")}`);
32451
+ + })
32452
+ + .catch(() => {});
32453
+ }
32454
  diff --git a/lib/browser/event-channel.test.mjs b/lib/browser/event-channel.test.mjs
32455
  new file mode 100644
32456
  index 0000000..90b7506
 
39599
  + const ids: ShortcutId[] = ["terminal", "changes", "browser", "files", "expandPane", ...(mac ? ["closePane" as const] : [])];
39600
  + return ids.some((id) => isShortcut(event, id, mac));
39601
  +}
39602
+ diff --git a/lib/skill-choices.test.mjs b/lib/skill-choices.test.mjs
39603
+ new file mode 100644
39604
+ index 0000000..fa03ef3
39605
+ --- /dev/null
39606
+ +++ b/lib/skill-choices.test.mjs
39607
+ @@ -0,0 +1,74 @@
39608
+ +import assert from "node:assert/strict";
39609
+ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
39610
+ +import { tmpdir } from "node:os";
39611
+ +import path from "node:path";
39612
+ +import test from "node:test";
39613
+ +import { createJiti } from "jiti";
39614
+ +
39615
+ +const jiti = createJiti(import.meta.url, { alias: { "@": process.cwd() }, interopDefault: true });
39616
+ +const { applySkillChoices, globalSkillNameOf, readSkillChoices, recordSkillChoice } = await jiti.import("./skill-choices.ts");
39617
+ +
39618
+ +function setup(t) {
39619
+ + const dir = mkdtempSync(path.join(tmpdir(), "pi-web-skill-choices-"));
39620
+ + t.after(() => rmSync(dir, { recursive: true, force: true }));
39621
+ + const root = path.join(dir, "home", ".agents", "skills");
39622
+ + const file = path.join(dir, "agent", "pi-web", "skill-choices.json");
39623
+ + const skill = (name, disabled) => {
39624
+ + mkdirSync(path.join(root, name), { recursive: true });
39625
+ + const p = path.join(root, name, "SKILL.md");
39626
+ + writeFileSync(p, `---\nname: ${name}\n${disabled ? "disable-model-invocation: true\n" : ""}description: ${name} skill\n---\n\n# ${name}\n`);
39627
+ + return p;
39628
+ + };
39629
+ + return { dir, root, file, skill };
39630
+ +}
39631
+ +
39632
+ +const disabled = (p) => /disable-model-invocation: true/.test(readFileSync(p, "utf8"));
39633
+ +
39634
+ +test("only a skill under the global root gets a kept choice, symlinked or not", (t) => {
39635
+ + const { dir, root, file, skill } = setup(t);
39636
+ + const cli = skill("hf-cli", false);
39637
+ + assert.equal(globalSkillNameOf(cli, root), "hf-cli");
39638
+ + const link = path.join(dir, "agent", "skills", "hf-cli");
39639
+ + mkdirSync(path.dirname(link), { recursive: true });
39640
+ + symlinkSync(path.join(root, "hf-cli"), link);
39641
+ + assert.equal(globalSkillNameOf(path.join(link, "SKILL.md"), root), "hf-cli");
39642
+ + const own = path.join(dir, "agent", "skills", "mine", "SKILL.md");
39643
+ + mkdirSync(path.dirname(own), { recursive: true });
39644
+ + writeFileSync(own, "---\nname: mine\n---\n");
39645
+ + assert.equal(globalSkillNameOf(own, root), null);
39646
+ + recordSkillChoice(own, false, { root, file });
39647
+ + assert.deepEqual(readSkillChoices(file).skills, {});
39648
+ + assert.equal(globalSkillNameOf(path.join(root, "..", "elsewhere.md"), root), null);
39649
+ +});
39650
+ +
39651
+ +test("choices are kept and applied over the image's defaults on a new machine", (t) => {
39652
+ + const { root, file, skill } = setup(t);
39653
+ + skill("hf-cli", false);
39654
+ + const datasets = skill("huggingface-datasets", true);
39655
+ + recordSkillChoice(path.join(root, "hf-cli", "SKILL.md"), false, { root, file });
39656
+ + recordSkillChoice(datasets, true, { root, file });
39657
+ + assert.deepEqual(readSkillChoices(file).skills, { "hf-cli": false, "huggingface-datasets": true });
39658
+ +
39659
+ + // A new machine: the image's files again (hf-cli on, the rest off), a skill the user never touched, and one gone.
39660
+ + const cli = skill("hf-cli", false);
39661
+ + skill("huggingface-datasets", true);
39662
+ + const gradio = skill("huggingface-gradio", true);
39663
+ + recordSkillChoice(path.join(root, "huggingface-datasets", "SKILL.md"), true, { root, file });
39664
+ + writeFileSync(file, JSON.stringify({ version: 1, skills: { ...readSkillChoices(file).skills, removed: true, "../x": true } }));
39665
+ + const result = applySkillChoices({ root, file });
39666
+ + assert.deepEqual(result.applied.sort(), ["hf-cli", "huggingface-datasets"]);
39667
+ + assert.deepEqual(result.failed, []);
39668
+ + assert.equal(disabled(cli), true);
39669
+ + assert.equal(disabled(path.join(root, "huggingface-datasets", "SKILL.md")), false);
39670
+ + assert.equal(disabled(gradio), true, "an untouched skill keeps the image's default");
39671
+ + // Applying again changes nothing.
39672
+ + assert.deepEqual(applySkillChoices({ root, file }).applied, []);
39673
+ +});
39674
+ +
39675
+ +test("a missing or broken choices file means no choices", (t) => {
39676
+ + const { file } = setup(t);
39677
+ + assert.deepEqual(readSkillChoices(file).skills, {});
39678
+ + mkdirSync(path.dirname(file), { recursive: true });
39679
+ + writeFileSync(file, "{not json");
39680
+ + assert.deepEqual(readSkillChoices(file).skills, {});
39681
+ +});
39682
+ diff --git a/lib/skill-choices.ts b/lib/skill-choices.ts
39683
+ new file mode 100644
39684
+ index 0000000..51e8cd0
39685
+ --- /dev/null
39686
+ +++ b/lib/skill-choices.ts
39687
+ @@ -0,0 +1,98 @@
39688
+ +import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "fs";
39689
+ +import { homedir } from "os";
39690
+ +import path from "path";
39691
+ +import { getAgentDir } from "@earendil-works/pi-coding-agent";
39692
+ +import { setDisableModelInvocation } from "./skill-frontmatter";
39693
+ +
39694
+ +// A skill's on/off state is `disable-model-invocation` in its own SKILL.md. The
39695
+ +// skills a machine image installs in ~/.agents/skills come back from the image
39696
+ +// on every new machine, with the image's defaults, so an edit there alone is
39697
+ +// lost. The user's explicit choices for them are also kept in the agent dir,
39698
+ +// which a workspace keeps, and applied again when Pi Web starts.
39699
+ +
39700
+ +interface SkillChoices {
39701
+ + version: 1;
39702
+ + /** Skill directory name under ~/.agents/skills -> enabled. */
39703
+ + skills: Record<string, boolean>;
39704
+ +}
39705
+ +
39706
+ +export function globalSkillsRoot(home = homedir()): string {
39707
+ + return path.join(home, ".agents", "skills");
39708
+ +}
39709
+ +
39710
+ +export function skillChoicesFile(agentDir = getAgentDir()): string {
39711
+ + return path.join(agentDir, "pi-web", "skill-choices.json");
39712
+ +}
39713
+ +
39714
+ +function real(target: string): string | null {
39715
+ + try {
39716
+ + return realpathSync(target);
39717
+ + } catch {
39718
+ + return null;
39719
+ + }
39720
+ +}
39721
+ +
39722
+ +/** The skill's directory name when `filePath` is a skill under the global root (a symlink to one too). */
39723
+ +export function globalSkillNameOf(filePath: string, root = globalSkillsRoot()): string | null {
39724
+ + const file = real(filePath);
39725
+ + const base = real(root);
39726
+ + if (!file || !base) return null;
39727
+ + const relative = path.relative(base, file);
39728
+ + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return null;
39729
+ + const [name, ...rest] = relative.split(path.sep);
39730
+ + return rest.length ? name : null;
39731
+ +}
39732
+ +
39733
+ +export function readSkillChoices(file = skillChoicesFile()): SkillChoices {
39734
+ + try {
39735
+ + const parsed = JSON.parse(readFileSync(file, "utf8")) as Partial<SkillChoices>;
39736
+ + const skills: Record<string, boolean> = {};
39737
+ + for (const [name, enabled] of Object.entries(parsed.skills ?? {})) {
39738
+ + if (typeof enabled === "boolean" && /^[\w.-]+$/.test(name)) skills[name] = enabled;
39739
+ + }
39740
+ + return { version: 1, skills };
39741
+ + } catch {
39742
+ + return { version: 1, skills: {} };
39743
+ + }
39744
+ +}
39745
+ +
39746
+ +/** Keeps the user's choice for a global skill they just toggled; other skills keep theirs in their own files. */
39747
+ +export function recordSkillChoice(
39748
+ + filePath: string,
39749
+ + enabled: boolean,
39750
+ + options: { root?: string; file?: string } = {},
39751
+ +): void {
39752
+ + const name = globalSkillNameOf(filePath, options.root);
39753
+ + if (!name) return;
39754
+ + const file = options.file ?? skillChoicesFile();
39755
+ + const choices = readSkillChoices(file);
39756
+ + if (choices.skills[name] === enabled) return;
39757
+ + choices.skills[name] = enabled;
39758
+ + mkdirSync(path.dirname(file), { recursive: true });
39759
+ + const tmp = `${file}.${process.pid}.tmp`;
39760
+ + writeFileSync(tmp, `${JSON.stringify(choices, null, 2)}\n`, "utf8");
39761
+ + renameSync(tmp, file);
39762
+ +}
39763
+ +
39764
+ +/** Applies the kept choices to the global skills present; a skill no longer installed is left in the file. */
39765
+ +export function applySkillChoices(options: { root?: string; file?: string } = {}): { applied: string[]; failed: string[] } {
39766
+ + const root = options.root ?? globalSkillsRoot();
39767
+ + const { skills } = readSkillChoices(options.file);
39768
+ + const applied: string[] = [];
39769
+ + const failed: string[] = [];
39770
+ + for (const [name, enabled] of Object.entries(skills)) {
39771
+ + const skillFile = path.join(root, name, "SKILL.md");
39772
+ + if (!existsSync(skillFile)) continue;
39773
+ + try {
39774
+ + const content = readFileSync(skillFile, "utf8");
39775
+ + const updated = setDisableModelInvocation(content, !enabled);
39776
+ + if (updated !== content) {
39777
+ + writeFileSync(skillFile, updated, "utf8");
39778
+ + applied.push(name);
39779
+ + }
39780
+ + } catch {
39781
+ + failed.push(name);
39782
+ + }
39783
+ + }
39784
+ + return { applied, failed };
39785
+ +}
39786
  diff --git a/lib/step-summary.test.mjs b/lib/step-summary.test.mjs
39787
  new file mode 100644
39788
  index 0000000..6cbe005
sandbox/AGENTS.md CHANGED
@@ -11,7 +11,7 @@ You run inside a disposable Hugging Face Sandbox (a container on HF Jobs, in the
11
 
12
  ## Hugging Face
13
 
14
- - The Hugging Face CLI `hf` is installed, and Hugging Face skills are in `~/.agents/skills` (`hf-cli`, `huggingface-spaces`, `huggingface-datasets`, `huggingface-llm-trainer`, `huggingface-gradio`, ...). Use them for anything on the Hub.
15
  - The Space lends the token of the user's sign-in to this machine: `~/.cache/huggingface/token` holds their scoped, expiring token, so `hf`, `huggingface_hub` and `git` (clone and push on huggingface.co) act as that user, within the permissions they granted. Check with `hf auth whoami`. The token is renewed in place; read the file when you need it rather than copying it.
16
  - If there is no token, the user has taken it back or their sign-in lapsed: do the rest of the work, then tell them to choose *Sign in again* or *Lend my token* in the Workspace menu (bottom of the sidebar), after which the token appears within seconds. Do not run `hf auth login` and never ask for a token in the chat.
17
  - Never print, log, copy or commit the token. For tools that only read `HF_TOKEN`, pass it for that command: `HF_TOKEN=$(cat ~/.cache/huggingface/token) node script.mjs`.
 
11
 
12
  ## Hugging Face
13
 
14
+ - The Hugging Face CLI `hf` is installed, with its skill `hf-cli`: use it for anything on the Hub. More Hugging Face skills (Spaces, datasets, training, Gradio, ...) are installed but off unless the user turned them on in Settings > Skills; the ones that are on are among your skills.
15
  - The Space lends the token of the user's sign-in to this machine: `~/.cache/huggingface/token` holds their scoped, expiring token, so `hf`, `huggingface_hub` and `git` (clone and push on huggingface.co) act as that user, within the permissions they granted. Check with `hf auth whoami`. The token is renewed in place; read the file when you need it rather than copying it.
16
  - If there is no token, the user has taken it back or their sign-in lapsed: do the rest of the work, then tell them to choose *Sign in again* or *Lend my token* in the Workspace menu (bottom of the sidebar), after which the token appears within seconds. Do not run `hf auth login` and never ask for a token in the chat.
17
  - Never print, log, copy or commit the token. For tools that only read `HF_TOKEN`, pass it for that command: `HF_TOKEN=$(cat ~/.cache/huggingface/token) node script.mjs`.
sandbox/skill-defaults.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """skill-defaults.py <skills dir> <enabled skill>...: the image's skill defaults. The listed skills are on,
2
+ every other one is off (`disable-model-invocation: true`: not offered to the model, still usable as
3
+ /skill:<name>, and turned on in Pi Web's Settings > Skills). Pi Web keeps a user's own choices in the agent
4
+ dir and puts them back on every new machine (lib/skill-choices.ts)."""
5
+ import pathlib, re, sys
6
+
7
+ root, enabled = pathlib.Path(sys.argv[1]), set(sys.argv[2:])
8
+ skills = sorted(root.glob("*/SKILL.md"))
9
+ assert any(p.parent.name in enabled for p in skills), f"none of {sorted(enabled)} in {root}"
10
+ for skill in skills:
11
+ name, text = skill.parent.name, skill.read_text()
12
+ head = re.match(r"\A---[ \t]*\n(.*?\n)---", text, re.S)
13
+ assert head, f"{name}: no frontmatter"
14
+ body = re.sub(r"(?m)^disable-model-invocation:.*\n", "", head.group(1))
15
+ if name not in enabled:
16
+ body += "disable-model-invocation: true\n"
17
+ skill.write_text(text[: head.start(1)] + body + text[head.end(1):])
18
+ print("on " if name in enabled else "off", name)