9 Commits

Author SHA1 Message Date
Advait Paliwal
b3a82d4a92 switch release workflow to binary only 2026-04-10 11:02:50 -07:00
Advait Paliwal
790824af20 verify rpc and website gates 2026-04-10 10:49:54 -07:00
Advait Paliwal
4137a29507 remove stale web access override 2026-04-10 10:20:31 -07:00
Advait Paliwal
5b9362918e document local model setup 2026-04-09 13:45:19 -07:00
Advait Paliwal
bfa538fa00 triage remaining tracker fixes 2026-04-09 10:34:29 -07:00
Advait Paliwal
96234425ba harden installers rendering and dependency hygiene 2026-04-09 10:27:23 -07:00
Advait Paliwal
3148f2e62b fix startup packaging and content guardrails 2026-04-09 10:09:05 -07:00
Advait Paliwal
554350cc0e Finish backlog cleanup for Pi integration 2026-03-31 11:02:07 -07:00
Advait Paliwal
d9812cf4f2 Fix Pi package updates and merge feynman-model 2026-03-31 09:18:05 -07:00
71 changed files with 2716 additions and 288 deletions

View File

@@ -13,7 +13,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2404
outputs:
version: ${{ steps.version.outputs.version }}
should_publish: ${{ steps.version.outputs.should_publish }}
should_release: ${{ steps.version.outputs.should_release }}
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v5
@@ -21,38 +21,20 @@ jobs:
node-version: 24.14.0
- id: version
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
CURRENT=$(npm view @companion-ai/feynman version 2>/dev/null || echo "0.0.0")
LOCAL=$(node -p "require('./package.json').version")
echo "version=$LOCAL" >> "$GITHUB_OUTPUT"
if [ "$CURRENT" != "$LOCAL" ]; then
echo "should_publish=true" >> "$GITHUB_OUTPUT"
if gh release view "v$LOCAL" >/dev/null 2>&1; then
echo "should_release=false" >> "$GITHUB_OUTPUT"
else
echo "should_publish=false" >> "$GITHUB_OUTPUT"
echo "should_release=true" >> "$GITHUB_OUTPUT"
fi
publish-npm:
needs: version-check
if: needs.version-check.outputs.should_publish == 'true'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v5
with:
node-version: 24.14.0
registry-url: https://registry.npmjs.org
- run: npm ci --ignore-scripts
- run: npm run build
- run: npm test
- run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
build-native-bundles:
needs: version-check
if: needs.version-check.outputs.should_publish == 'true'
if: needs.version-check.outputs.should_release == 'true'
strategy:
fail-fast: false
matrix:
@@ -101,9 +83,8 @@ jobs:
release-github:
needs:
- version-check
- publish-npm
- build-native-bundles
if: needs.version-check.outputs.should_publish == 'true' && needs.build-native-bundles.result == 'success' && needs.publish-npm.result == 'success'
if: needs.version-check.outputs.should_release == 'true' && needs.build-native-bundles.result == 'success'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write

View File

@@ -95,3 +95,75 @@ Use this file to track chronology, not release notes. Keep entries short, factua
- Failed / learned: An initial local `build:native-bundle` check failed because `npm pack` and `build:native-bundle` were run in parallel, and `prepack` intentionally removes `dist/release`; rerunning `npm run build:native-bundle` sequentially succeeded.
- Blockers: None in the repo; publishing still depends on the GitHub workflow running on the bumped version.
- Next: Push the `0.2.16` release bump and monitor npm/GitHub release publication.
### 2026-03-31 10:45 PDT — pi-maintenance-issues-prs
- Objective: Triage open Pi-related issues/PRs, fix the concrete package update regression, and refresh Pi dependencies against current upstream releases.
- Changed: Pinned direct package-manager operations (`feynman update`, `feynman packages install`) to Feynman's npm prefix by exporting `FEYNMAN_NPM_PREFIX`, `NPM_CONFIG_PREFIX`, and `npm_config_prefix` before invoking Pi's `DefaultPackageManager`; bumped `@mariozechner/pi-ai` and `@mariozechner/pi-coding-agent` from `0.62.0` to `0.64.0`; adapted `src/model/registry.ts` to the new `ModelRegistry.create(...)` factory; integrated PR #15's `/feynman-model` command on top of current `main`.
- Verified: Ran `npm test`, `npm run typecheck`, and `npm run build` successfully after the dependency bump and PR integration; confirmed upstream `pi-coding-agent@0.64.0` still uses `npm install -g` for user-scope package updates, so the Feynman-side prefix fix is still required.
- Failed / learned: PR #14 is a stale branch with no clean merge path against current `main`; the only user-facing delta is the ValiChord prompt/skill addition, and the branch also carries unrelated release churn plus demo-style material, so it was not merged in this pass.
- Blockers: None in the local repo state; remote merge/push still depends on repository credentials and branch policy.
- Next: If remote write access is available, commit and push the validated maintenance changes, then close issue #22 and resolve PR #15 as merged while leaving PR #14 unmerged pending a cleaned-up, non-promotional resubmission.
### 2026-03-31 12:05 PDT — pi-backlog-cleanup-round-2
- Objective: Finish the remaining high-confidence open tracker items after the Pi 0.64.0 upgrade instead of leaving the issue list half-reconciled.
- Changed: Added a Windows extension-loader patch helper so Feynman rewrites Pi extension imports to `file://` URLs on Windows before interactive startup; added `/commands`, `/tools`, and `/capabilities` discovery commands and surfaced `/hotkeys` plus `/service-tier` in help metadata; added explicit service-tier support via `feynman model tier`, `--service-tier`, status/doctor output, and a provider-payload hook that passes `service_tier` only to supported OpenAI/OpenAI Codex/Anthropic models; added Exa provider recognition to Feynman's web-search status layer and vendored `pi-web-access`.
- Verified: Ran `npm test`, `npm run typecheck`, and `npm run build`; smoke-imported the modified vendored `pi-web-access` modules with `node --import tsx`.
- Failed / learned: The remaining ValiChord PR is still stale and mixes a real prompt/skill update with unrelated branch churn; it is a review/triage item, not a clean merge candidate.
- Blockers: No local build blockers remain; issue/PR closure still depends on the final push landing on `main`.
- Next: Push the verified cleanup commit, then close issues fixed by the dependency bump plus the new discoverability/service-tier/Windows patches, and close the stale ValiChord PR explicitly instead of leaving it open indefinitely.
### 2026-04-09 09:37 PDT — windows-startup-import-specifiers
- Objective: Fix Windows startup failures where `feynman` exits before the Pi child process initializes.
- Changed: Converted the Node preload module paths passed via `node --import` in `src/pi/launch.ts` to `file://` specifiers using a new `toNodeImportSpecifier(...)` helper in `src/pi/runtime.ts`; expanded `scripts/patch-embedded-pi.mjs` so it also patches the bundled workspace copy of Pi's extension loader when present.
- Verified: Added a regression test in `tests/pi-runtime.test.ts` covering absolute-path to `file://` conversion for preload imports; ran `npm test`, `npm run typecheck`, and `npm run build`.
- Failed / learned: The raw Windows `ERR_UNSUPPORTED_ESM_URL_SCHEME` stack is more consistent with Node rejecting the child-process `--import C:\\...` preload before Pi starts than with a normal in-app extension load failure.
- Blockers: Windows runtime execution was not available locally, so the fix is verified by code path inspection and automated tests rather than an actual Windows shell run.
- Next: Ask the affected user to reinstall or update to the next published package once released, and confirm the Windows REPL now starts from a normal PowerShell session.
### 2026-04-09 11:02 PDT — tracker-hardening-pass
- Objective: Triage the open repo backlog, land the highest-signal fixes locally, and add guardrails against stale promotional workflow content.
- Changed: Hardened Windows launch paths in `bin/feynman.js`, `scripts/build-native-bundle.mjs`, and `scripts/install/install.ps1`; set npm prefix overrides earlier in `scripts/patch-embedded-pi.mjs`; added a `pi-web-access` runtime patch helper plus `FEYNMAN_WEB_SEARCH_CONFIG` env wiring so bundled web search reads the same `~/.feynman/web-search.json` that doctor/status report; taught `src/pi/web-access.ts` to honor the legacy `route` key; fixed bundled skill references and expanded the skills-only installers/docs to ship the prompt and guidance files those skills reference; added regression tests for config paths, catalog snapshot edges, skill-path packaging, `pi-web-access` patching, and blocked promotional content.
- Verified: Ran `npm test`, `npm run typecheck`, and `npm run build` successfully after the full maintenance pass.
- Failed / learned: The skills-only install issue was not just docs drift; the shipped `SKILL.md` files referenced prompt paths that only made sense after installation, so the repo needed both path normalization and packaging changes.
- Blockers: Remote issue/PR closure and merge actions still depend on the final reviewed branch state being pushed.
- Next: Push the validated fixes, close the duplicate Windows/reporting issues they supersede, reject the promotional ValiChord PR explicitly, and then review whether the remaining docs-only or feature PRs should be merged separately.
### 2026-04-09 10:28 PDT — verification-and-security-pass
- Objective: Run a deeper install/security verification pass against the post-cleanup `0.2.17` tree instead of assuming the earlier targeted fixes covered the shipped artifacts.
- Changed: Reworked `extensions/research-tools/header.ts` to use `@mariozechner/pi-tui` width-aware helpers for truncation/wrapping so wide Unicode text does not overflow custom header rows; changed `src/pi/launch.ts` to stop mirroring child crash signals back onto the parent process and instead emit a conventional exit code; added `FEYNMAN_INSTALL_SKILLS_ARCHIVE_URL` overrides to the skills installers for pre-release smoke testing; aligned root and website dependency trees with patched transitive versions using npm `overrides`; fixed `src/pi/web-access.ts` so `search status` respects `FEYNMAN_HOME` semantics instead of hardcoding the current shell home directory; added `tests/pi-launch.test.ts`.
- Verified: Ran `npm test`, `npm run typecheck`, `npm run build`, `cd website && npm run build`, `npm run build:native-bundle`; smoke-tested `scripts/install/install.sh` against a locally served `dist/release/feynman-0.2.17-darwin-arm64.tar.gz`; smoke-tested `scripts/install/install-skills.sh` against a local source archive; confirmed installed `feynman --version`, `feynman --help`, `feynman doctor`, and packaged `feynman search status` work from the installed bundle; `npm audit --omit=dev` is clean in the root app and website after overrides.
- Failed / learned: The first packaged `search status` smoke test still showed the user home path because the native bundle had been built before the `FEYNMAN_HOME` path fix; rebuilding the native bundle resolved that mismatch.
- Blockers: PowerShell runtime was unavailable locally, so Windows installer execution remained code-path validated rather than actually executed.
- Next: Push the second-pass hardening commit, then keep issue `#46` and issue `#47` open until users on the affected Linux/CJK environments confirm whether the launcher/header fixes fully resolve them.
### 2026-04-09 10:36 PDT — remaining-tracker-triage-pass
- Objective: Reduce the remaining open tracker items by landing the lowest-risk missing docs/catalog updates and a targeted Cloud Code Assist compatibility patch instead of only hand-triaging them.
- Changed: Added MiniMax M2.7 recommendation preferences in `src/model/catalog.ts`; documented model switching, authenticated-provider visibility, and `/feynman-model` subagent overrides in `website/src/content/docs/getting-started/configuration.md` and `website/src/content/docs/reference/slash-commands.md`; added a runtime patch helper in `scripts/lib/pi-google-legacy-schema-patch.mjs` and wired `scripts/patch-embedded-pi.mjs` to normalize JSON Schema `const` into `enum` for the legacy `parameters` field used by Cloud Code Assist Claude models.
- Verified: Ran `npm test`, `npm run typecheck`, `npm run build`, and `cd website && npm run build` after the patch/helper/docs changes.
- Failed / learned: The MiniMax provider catalog in Pi already uses canonical IDs like `MiniMax-M2.7`, so the only failure during validation was a test assertion using the wrong casing rather than a runtime bug.
- Blockers: The Cloud Code Assist fix is validated by targeted patch tests and code-path review rather than an end-to-end Google account repro in this environment.
- Next: Push the tracker-triage commit, close the docs/MiniMax PRs as superseded by main, close the support-style model issues against the new docs, and decide whether the remaining feature requests should be left open or closed as not planned/upstream-dependent.
### 2026-04-10 10:22 PDT — web-access-stale-override-fix
- Objective: Fix the new `ctx.modelRegistry.getApiKeyAndHeaders is not a function` / stale `search-filter.js` report without reintroducing broad vendor drift.
- Changed: Removed the stale `.feynman/vendor-overrides/pi-web-access/*` files and removed `syncVendorOverride` from `scripts/patch-embedded-pi.mjs`; kept the targeted `pi-web-access` runtime config-path patch; added `feynman search set <provider> [api-key]` and `feynman search clear` commands with a shared save path in `src/pi/web-access.ts`.
- Verified: Ran `npm test`, `npm run typecheck`, `npm run build`; ran `node scripts/patch-embedded-pi.mjs`, confirmed the installed `pi-web-access/index.ts` has no `search-filter` / condense helper references, and smoke-imported `./.feynman/npm/node_modules/pi-web-access/index.ts`; ran `npm pack --dry-run` and confirmed stale `vendor-overrides` files are no longer in the package tarball.
- Failed / learned: The public Linux installer Docker test was attempted but Docker Desktop became unresponsive even for simple `docker run node:22-bookworm node -v` commands; the earlier Linux npm-artifact container smoke remains valid, but this specific public-installer run is blocked by the local Docker daemon.
- Blockers: Issue `#54` is too underspecified to fix directly without logs; public Linux installer behavior still needs a stable Docker daemon or a real Linux shell to reproduce the user's exact npm errors.
- Next: Push the stale-override fix, close PR `#52` and PR `#53` as superseded/merged-by-main once pushed, and ask for logs on issue `#54` instead of guessing.
### 2026-04-10 10:49 PDT — rpc-and-website-verification-pass
- Objective: Exercise the Feynman wrapper's RPC mode and the website quality gates that were not fully covered by the prior passes.
- Changed: Added `--mode <text|json|rpc>` pass-through support in the Feynman wrapper and skipped terminal clearing in RPC mode; added `@astrojs/check` to the website dev dependencies, fixed React Refresh lint violations in the generated UI components by exporting only components, and added safe website dependency overrides for dev-audit findings.
- Verified: Ran a JSONL RPC smoke test through `node bin/feynman.js --mode rpc` with `get_state`; ran `npm test`, `npm run typecheck`, `npm run build`, `cd website && npm run lint`, `cd website && npm run typecheck`, `cd website && npm run build`, full root `npm audit`, full website `npm audit`, and `npm run build:native-bundle`.
- Failed / learned: Website typecheck was previously a no-op prompt because `@astrojs/check` was missing; installing it exposed dev-audit findings that needed explicit overrides before the full website audit was clean.
- Blockers: Docker Desktop remained unreliable after restart attempts, so this pass still does not include a second successful public-installer Linux Docker run.
- Next: Push the RPC/website verification commit and keep future Docker/public-installer validation separate from repo correctness unless Docker is stable.

View File

@@ -59,6 +59,7 @@ npm run build
- Avoid refactor-only PRs unless they are necessary to unblock a real fix or requested by a maintainer.
- Do not silently change release behavior, installer behavior, or runtime defaults without documenting the reason in the PR.
- Use American English in docs, comments, prompts, UI copy, and examples.
- Do not add bundled prompts, skills, or docs whose primary purpose is to market, endorse, or funnel users toward a third-party product or service. Product integrations must be justified by user-facing utility and written in neutral language.
## Repo-Specific Checks

View File

@@ -25,9 +25,11 @@ curl -fsSL https://feynman.is/install | bash
irm https://feynman.is/install.ps1 | iex
```
The one-line installer fetches the latest tagged release. To pin a version, pass it explicitly, for example `curl -fsSL https://feynman.is/install | bash -s -- 0.2.16`.
The one-line installer fetches the latest tagged release. To pin a version, pass it explicitly, for example `curl -fsSL https://feynman.is/install | bash -s -- 0.2.17`.
If you install via `pnpm` or `bun` instead of the standalone bundle, Feynman requires Node.js `20.19.0` or newer.
The installer downloads a standalone native bundle with its own Node.js runtime.
Local models are supported through the custom-provider flow. For Ollama, run `feynman setup`, choose `Custom provider (baseUrl + API key)`, use `openai-completions`, and point it at `http://localhost:11434/v1`.
### Skills Only
@@ -63,6 +65,8 @@ curl -fsSL https://feynman.is/install-skills | bash -s -- --repo
That installs into `.agents/skills/feynman` under the current repository.
These installers download the bundled `skills/` and `prompts/` trees plus the repo guidance files referenced by those skills. They do not install the Feynman terminal, bundled Node runtime, auth storage, or Pi packages.
---
### What you type → what happens

View File

@@ -1,4 +1,7 @@
#!/usr/bin/env node
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
const MIN_NODE_VERSION = "20.19.0";
function parseNodeVersion(version) {
@@ -27,5 +30,7 @@ if (compareNodeVersions(parseNodeVersion(process.versions.node), parseNodeVersio
: "curl -fsSL https://feynman.is/install | bash");
process.exit(1);
}
await import(new URL("../scripts/patch-embedded-pi.mjs", import.meta.url).href);
await import(new URL("../dist/index.js", import.meta.url).href);
const here = import.meta.dirname;
await import(pathToFileURL(resolve(here, "..", "scripts", "patch-embedded-pi.mjs")).href);
await import(pathToFileURL(resolve(here, "..", "dist", "index.js")).href);

View File

@@ -1,9 +1,12 @@
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { registerAlphaTools } from "./research-tools/alpha.js";
import { registerDiscoveryCommands } from "./research-tools/discovery.js";
import { registerFeynmanModelCommand } from "./research-tools/feynman-model.js";
import { installFeynmanHeader } from "./research-tools/header.js";
import { registerHelpCommand } from "./research-tools/help.js";
import { registerInitCommand, registerOutputsCommand } from "./research-tools/project.js";
import { registerServiceTierControls } from "./research-tools/service-tier.js";
export default function researchTools(pi: ExtensionAPI): void {
const cache: { agentSummaryPromise?: Promise<{ agents: string[]; chains: string[] }> } = {};
@@ -17,7 +20,10 @@ export default function researchTools(pi: ExtensionAPI): void {
});
registerAlphaTools(pi);
registerDiscoveryCommands(pi);
registerFeynmanModelCommand(pi);
registerHelpCommand(pi);
registerInitCommand(pi);
registerOutputsCommand(pi);
registerServiceTierControls(pi);
}

View File

@@ -0,0 +1,130 @@
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { resolve } from "node:path";
import type { ExtensionAPI, SlashCommandInfo, ToolInfo } from "@mariozechner/pi-coding-agent";
function resolveFeynmanSettingsPath(): string {
const configured = process.env.PI_CODING_AGENT_DIR?.trim();
const agentDir = configured
? configured.startsWith("~/")
? resolve(homedir(), configured.slice(2))
: resolve(configured)
: resolve(homedir(), ".feynman", "agent");
return resolve(agentDir, "settings.json");
}
function readConfiguredPackages(): string[] {
const settingsPath = resolveFeynmanSettingsPath();
if (!existsSync(settingsPath)) return [];
try {
const parsed = JSON.parse(readFileSync(settingsPath, "utf8")) as { packages?: unknown[] };
return Array.isArray(parsed.packages)
? parsed.packages
.map((entry) => {
if (typeof entry === "string") return entry;
if (!entry || typeof entry !== "object") return undefined;
const record = entry as { source?: unknown };
return typeof record.source === "string" ? record.source : undefined;
})
.filter((entry): entry is string => Boolean(entry))
: [];
} catch {
return [];
}
}
function formatSourceLabel(sourceInfo: { source: string; path: string }): string {
if (sourceInfo.source === "local") {
if (sourceInfo.path.includes("/prompts/")) return "workflow";
if (sourceInfo.path.includes("/extensions/")) return "extension";
return "local";
}
return sourceInfo.source.replace(/^npm:/, "").replace(/^git:/, "");
}
function formatCommandLine(command: SlashCommandInfo): string {
const source = formatSourceLabel(command.sourceInfo);
return `/${command.name}${command.description ?? ""} [${source}]`;
}
function summarizeToolParameters(tool: ToolInfo): string {
const properties =
tool.parameters &&
typeof tool.parameters === "object" &&
"properties" in tool.parameters &&
tool.parameters.properties &&
typeof tool.parameters.properties === "object"
? Object.keys(tool.parameters.properties as Record<string, unknown>)
: [];
return properties.length > 0 ? properties.join(", ") : "no parameters";
}
function formatToolLine(tool: ToolInfo): string {
const source = formatSourceLabel(tool.sourceInfo);
return `${tool.name}${tool.description ?? ""} [${source}]`;
}
export function registerDiscoveryCommands(pi: ExtensionAPI): void {
pi.registerCommand("commands", {
description: "Browse all available slash commands, including package and built-in commands.",
handler: async (_args, ctx) => {
const commands = pi
.getCommands()
.slice()
.sort((left, right) => left.name.localeCompare(right.name));
const items = commands.map((command) => formatCommandLine(command));
const selected = await ctx.ui.select("Slash Commands", items);
if (!selected) return;
ctx.ui.setEditorText(selected.split(" — ")[0] ?? "");
ctx.ui.notify(`Prefilled ${selected.split(" — ")[0]}`, "info");
},
});
pi.registerCommand("tools", {
description: "Browse all callable tools with their source and parameter summary.",
handler: async (_args, ctx) => {
const tools = pi
.getAllTools()
.slice()
.sort((left, right) => left.name.localeCompare(right.name));
const selected = await ctx.ui.select("Tools", tools.map((tool) => formatToolLine(tool)));
if (!selected) return;
const toolName = selected.split(" — ")[0] ?? selected;
const tool = tools.find((entry) => entry.name === toolName);
if (!tool) return;
ctx.ui.notify(`${tool.name}: ${summarizeToolParameters(tool)}`, "info");
},
});
pi.registerCommand("capabilities", {
description: "Show installed packages, discovery entrypoints, and high-level runtime capability counts.",
handler: async (_args, ctx) => {
const commands = pi.getCommands();
const tools = pi.getAllTools();
const workflows = commands.filter((command) => formatSourceLabel(command.sourceInfo) === "workflow");
const packages = readConfiguredPackages();
const items = [
`Commands: ${commands.length}`,
`Workflows: ${workflows.length}`,
`Tools: ${tools.length}`,
`Packages: ${packages.length}`,
"--- Discovery ---",
"/commands — browse slash commands",
"/tools — inspect callable tools",
"/hotkeys — view keyboard shortcuts",
"/service-tier — set request tier for supported providers",
"--- Installed Packages ---",
...packages.map((pkg) => pkg),
];
const selected = await ctx.ui.select("Capabilities", items);
if (!selected || selected.startsWith("---")) return;
if (selected.startsWith("/")) {
ctx.ui.setEditorText(selected.split(" — ")[0] ?? selected);
ctx.ui.notify(`Prefilled ${selected.split(" — ")[0]}`, "info");
}
},
});
}

View File

@@ -0,0 +1,309 @@
import { type Dirent, existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { basename, join, resolve } from "node:path";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
const FRONTMATTER_PATTERN = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/;
const INHERIT_MAIN = "__inherit_main__";
type FrontmatterDocument = {
lines: string[];
body: string;
eol: string;
trailingNewline: boolean;
};
type SubagentModelConfig = {
agent: string;
model?: string;
filePath: string;
};
type SelectOption<T> = {
label: string;
value: T;
};
type CommandContext = Parameters<Parameters<ExtensionAPI["registerCommand"]>[1]["handler"]>[1];
type TargetChoice =
| { type: "main" }
| { type: "subagent"; agent: string; model?: string };
function expandHomePath(value: string): string {
if (value === "~") return homedir();
if (value.startsWith("~/")) return resolve(homedir(), value.slice(2));
return value;
}
function resolveFeynmanAgentDir(): string {
const configured = process.env.PI_CODING_AGENT_DIR ?? process.env.FEYNMAN_CODING_AGENT_DIR;
if (configured?.trim()) {
return resolve(expandHomePath(configured.trim()));
}
return resolve(homedir(), ".feynman", "agent");
}
function formatModelSpec(model: { provider: string; id: string }): string {
return `${model.provider}/${model.id}`;
}
function detectEol(text: string): string {
return text.includes("\r\n") ? "\r\n" : "\n";
}
function normalizeLineEndings(text: string): string {
return text.replace(/\r\n/g, "\n");
}
function parseFrontmatterDocument(text: string): FrontmatterDocument | null {
const normalized = normalizeLineEndings(text);
const match = normalized.match(FRONTMATTER_PATTERN);
if (!match) return null;
return {
lines: match[1].split("\n"),
body: match[2] ?? "",
eol: detectEol(text),
trailingNewline: normalized.endsWith("\n"),
};
}
function serializeFrontmatterDocument(document: FrontmatterDocument): string {
const normalized = `---\n${document.lines.join("\n")}\n---\n${document.body}`;
const withTrailingNewline =
document.trailingNewline && !normalized.endsWith("\n") ? `${normalized}\n` : normalized;
return document.eol === "\n" ? withTrailingNewline : withTrailingNewline.replace(/\n/g, "\r\n");
}
function parseFrontmatterKey(line: string): string | undefined {
const match = line.match(/^\s*([A-Za-z0-9_-]+)\s*:/);
return match?.[1]?.toLowerCase();
}
function getFrontmatterValue(lines: string[], key: string): string | undefined {
const normalizedKey = key.toLowerCase();
for (const line of lines) {
const parsedKey = parseFrontmatterKey(line);
if (parsedKey !== normalizedKey) continue;
const separatorIndex = line.indexOf(":");
if (separatorIndex === -1) return undefined;
const value = line.slice(separatorIndex + 1).trim();
return value.length > 0 ? value : undefined;
}
return undefined;
}
function upsertFrontmatterValue(lines: string[], key: string, value: string): string[] {
const normalizedKey = key.toLowerCase();
const nextLines = [...lines];
const existingIndex = nextLines.findIndex((line) => parseFrontmatterKey(line) === normalizedKey);
const serialized = `${key}: ${value}`;
if (existingIndex !== -1) {
nextLines[existingIndex] = serialized;
return nextLines;
}
const descriptionIndex = nextLines.findIndex((line) => parseFrontmatterKey(line) === "description");
const nameIndex = nextLines.findIndex((line) => parseFrontmatterKey(line) === "name");
const insertIndex = descriptionIndex !== -1 ? descriptionIndex + 1 : nameIndex !== -1 ? nameIndex + 1 : nextLines.length;
nextLines.splice(insertIndex, 0, serialized);
return nextLines;
}
function removeFrontmatterKey(lines: string[], key: string): string[] {
const normalizedKey = key.toLowerCase();
return lines.filter((line) => parseFrontmatterKey(line) !== normalizedKey);
}
function normalizeAgentName(name: string): string {
return name.trim().toLowerCase();
}
function getAgentsDir(agentDir: string): string {
return join(agentDir, "agents");
}
function listAgentFiles(agentsDir: string): string[] {
if (!existsSync(agentsDir)) return [];
return readdirSync(agentsDir, { withFileTypes: true })
.filter((entry: Dirent) => (entry.isFile() || entry.isSymbolicLink()) && entry.name.endsWith(".md"))
.filter((entry) => !entry.name.endsWith(".chain.md"))
.map((entry) => join(agentsDir, entry.name));
}
function readAgentConfig(filePath: string): SubagentModelConfig {
const content = readFileSync(filePath, "utf8");
const parsed = parseFrontmatterDocument(content);
const fallbackName = basename(filePath, ".md");
if (!parsed) return { agent: fallbackName, filePath };
return {
agent: getFrontmatterValue(parsed.lines, "name") ?? fallbackName,
model: getFrontmatterValue(parsed.lines, "model"),
filePath,
};
}
function listSubagentModelConfigs(agentDir: string): SubagentModelConfig[] {
return listAgentFiles(getAgentsDir(agentDir))
.map((filePath) => readAgentConfig(filePath))
.sort((left, right) => left.agent.localeCompare(right.agent));
}
function findAgentConfig(configs: SubagentModelConfig[], agentName: string): SubagentModelConfig | undefined {
const normalized = normalizeAgentName(agentName);
return (
configs.find((config) => normalizeAgentName(config.agent) === normalized) ??
configs.find((config) => normalizeAgentName(basename(config.filePath, ".md")) === normalized)
);
}
function getAgentConfigOrThrow(agentDir: string, agentName: string): SubagentModelConfig {
const configs = listSubagentModelConfigs(agentDir);
const target = findAgentConfig(configs, agentName);
if (target) return target;
if (configs.length === 0) {
throw new Error(`No subagent definitions found in ${getAgentsDir(agentDir)}.`);
}
const availableAgents = configs.map((config) => config.agent).join(", ");
throw new Error(`Unknown subagent: ${agentName}. Available agents: ${availableAgents}`);
}
function setSubagentModel(agentDir: string, agentName: string, modelSpec: string): void {
const normalizedModelSpec = modelSpec.trim();
if (!normalizedModelSpec) throw new Error("Model spec cannot be empty.");
const target = getAgentConfigOrThrow(agentDir, agentName);
const content = readFileSync(target.filePath, "utf8");
const parsed = parseFrontmatterDocument(content);
if (!parsed) {
const eol = detectEol(content);
const injected = `---${eol}name: ${target.agent}${eol}model: ${normalizedModelSpec}${eol}---${eol}${content}`;
writeFileSync(target.filePath, injected, "utf8");
return;
}
const nextLines = upsertFrontmatterValue(parsed.lines, "model", normalizedModelSpec);
if (nextLines.join("\n") !== parsed.lines.join("\n")) {
writeFileSync(target.filePath, serializeFrontmatterDocument({ ...parsed, lines: nextLines }), "utf8");
}
}
function unsetSubagentModel(agentDir: string, agentName: string): void {
const target = getAgentConfigOrThrow(agentDir, agentName);
const content = readFileSync(target.filePath, "utf8");
const parsed = parseFrontmatterDocument(content);
if (!parsed) return;
const nextLines = removeFrontmatterKey(parsed.lines, "model");
if (nextLines.join("\n") !== parsed.lines.join("\n")) {
writeFileSync(target.filePath, serializeFrontmatterDocument({ ...parsed, lines: nextLines }), "utf8");
}
}
async function selectOption<T>(
ctx: CommandContext,
title: string,
options: SelectOption<T>[],
): Promise<T | undefined> {
const selected = await ctx.ui.select(
title,
options.map((option) => option.label),
);
if (!selected) return undefined;
return options.find((option) => option.label === selected)?.value;
}
export function registerFeynmanModelCommand(pi: ExtensionAPI): void {
pi.registerCommand("feynman-model", {
description: "Open Feynman model menu (main + per-subagent overrides).",
handler: async (_args, ctx) => {
if (!ctx.hasUI) {
ctx.ui.notify("feynman-model requires interactive mode.", "error");
return;
}
try {
ctx.modelRegistry.refresh();
const availableModels = [...ctx.modelRegistry.getAvailable()].sort((left, right) =>
formatModelSpec(left).localeCompare(formatModelSpec(right)),
);
if (availableModels.length === 0) {
ctx.ui.notify("No models available.", "error");
return;
}
const agentDir = resolveFeynmanAgentDir();
const subagentConfigs = listSubagentModelConfigs(agentDir);
const currentMain = ctx.model ? formatModelSpec(ctx.model) : "(none)";
const targetOptions: SelectOption<TargetChoice>[] = [
{ label: `main (default): ${currentMain}`, value: { type: "main" } },
...subagentConfigs.map((config) => ({
label: `${config.agent}: ${config.model ?? "default"}`,
value: { type: "subagent" as const, agent: config.agent, model: config.model },
})),
];
const target = await selectOption(ctx, "Choose target", targetOptions);
if (!target) return;
if (target.type === "main") {
const selectedModel = await selectOption(
ctx,
"Select main model",
availableModels.map((model) => {
const spec = formatModelSpec(model);
const suffix = spec === currentMain ? " (current)" : "";
return { label: `${spec}${suffix}`, value: model };
}),
);
if (!selectedModel) return;
const success = await pi.setModel(selectedModel);
if (!success) {
ctx.ui.notify(`No API key found for ${selectedModel.provider}.`, "error");
return;
}
ctx.ui.notify(`Main model set to ${formatModelSpec(selectedModel)}.`, "info");
return;
}
const selectedSubagentModel = await selectOption(
ctx,
`Select model for ${target.agent}`,
[
{
label: target.model ? "(inherit main default)" : "(inherit main default) (current)",
value: INHERIT_MAIN,
},
...availableModels.map((model) => {
const spec = formatModelSpec(model);
const suffix = spec === target.model ? " (current)" : "";
return { label: `${spec}${suffix}`, value: spec };
}),
],
);
if (!selectedSubagentModel) return;
if (selectedSubagentModel === INHERIT_MAIN) {
unsetSubagentModel(agentDir, target.agent);
ctx.ui.notify(`${target.agent} now inherits the main model.`, "info");
return;
}
setSubagentModel(agentDir, target.agent, selectedSubagentModel);
ctx.ui.notify(`${target.agent} model set to ${selectedSubagentModel}.`, "info");
} catch (error) {
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
}
},
});
}

View File

@@ -4,6 +4,7 @@ import { execSync } from "node:child_process";
import { resolve as resolvePath } from "node:path";
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import {
APP_ROOT,
@@ -11,10 +12,8 @@ import {
FEYNMAN_VERSION,
} from "./shared.js";
const ANSI_RE = /\x1b\[[0-9;]*m/g;
function visibleLength(text: string): number {
return text.replace(ANSI_RE, "").length;
return visibleWidth(text);
}
function formatHeaderPath(path: string): string {
@@ -23,10 +22,8 @@ function formatHeaderPath(path: string): string {
}
function truncateVisible(text: string, maxVisible: number): string {
const raw = text.replace(ANSI_RE, "");
if (raw.length <= maxVisible) return text;
if (maxVisible <= 3) return ".".repeat(maxVisible);
return `${raw.slice(0, maxVisible - 3)}...`;
if (visibleWidth(text) <= maxVisible) return text;
return truncateToWidth(text, maxVisible, maxVisible <= 3 ? "" : "...");
}
function wrapWords(text: string, maxW: number): string[] {
@@ -34,12 +31,12 @@ function wrapWords(text: string, maxW: number): string[] {
const lines: string[] = [];
let cur = "";
for (let word of words) {
if (word.length > maxW) {
if (visibleWidth(word) > maxW) {
if (cur) { lines.push(cur); cur = ""; }
word = maxW > 3 ? `${word.slice(0, maxW - 1)}` : word.slice(0, maxW);
word = truncateToWidth(word, maxW, maxW > 3 ? "…" : "");
}
const test = cur ? `${cur} ${word}` : word;
if (cur && test.length > maxW) {
if (cur && visibleWidth(test) > maxW) {
lines.push(cur);
cur = word;
} else {
@@ -56,9 +53,10 @@ function padRight(text: string, width: number): string {
}
function centerText(text: string, width: number): string {
if (text.length >= width) return text.slice(0, width);
const left = Math.floor((width - text.length) / 2);
const right = width - text.length - left;
const textWidth = visibleWidth(text);
if (textWidth >= width) return truncateToWidth(text, width, "");
const left = Math.floor((width - textWidth) / 2);
const right = width - textWidth - left;
return `${" ".repeat(left)}${text}${" ".repeat(right)}`;
}
@@ -287,8 +285,8 @@ export function installFeynmanHeader(
if (activity) {
const maxActivityLen = leftW * 2;
const trimmed = activity.length > maxActivityLen
? `${activity.slice(0, maxActivityLen - 1)}`
const trimmed = visibleWidth(activity) > maxActivityLen
? truncateToWidth(activity, maxActivityLen, "…")
: activity;
leftLines.push("");
leftLines.push(theme.fg("accent", theme.bold("Last Activity")));

View File

@@ -0,0 +1,174 @@
import { homedir } from "node:os";
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
const FEYNMAN_SERVICE_TIERS = [
"auto",
"default",
"flex",
"priority",
"standard_only",
] as const;
type FeynmanServiceTier = (typeof FEYNMAN_SERVICE_TIERS)[number];
const SERVICE_TIER_SET = new Set<string>(FEYNMAN_SERVICE_TIERS);
const OPENAI_SERVICE_TIERS = new Set<FeynmanServiceTier>(["auto", "default", "flex", "priority"]);
const ANTHROPIC_SERVICE_TIERS = new Set<FeynmanServiceTier>(["auto", "standard_only"]);
type CommandContext = Parameters<Parameters<ExtensionAPI["registerCommand"]>[1]["handler"]>[1];
type SelectOption<T> = {
label: string;
value: T;
};
function resolveFeynmanSettingsPath(): string {
const configured = process.env.PI_CODING_AGENT_DIR?.trim();
const agentDir = configured
? configured.startsWith("~/")
? resolve(homedir(), configured.slice(2))
: resolve(configured)
: resolve(homedir(), ".feynman", "agent");
return resolve(agentDir, "settings.json");
}
function normalizeServiceTier(value: string | undefined): FeynmanServiceTier | undefined {
if (!value) return undefined;
const normalized = value.trim().toLowerCase();
return SERVICE_TIER_SET.has(normalized) ? (normalized as FeynmanServiceTier) : undefined;
}
function getConfiguredServiceTier(settingsPath: string): FeynmanServiceTier | undefined {
try {
const parsed = JSON.parse(readFileSync(settingsPath, "utf8")) as { serviceTier?: string };
return normalizeServiceTier(parsed.serviceTier);
} catch {
return undefined;
}
}
function setConfiguredServiceTier(settingsPath: string, tier: FeynmanServiceTier | undefined): void {
let settings: Record<string, unknown> = {};
try {
settings = JSON.parse(readFileSync(settingsPath, "utf8")) as Record<string, unknown>;
} catch {}
if (tier) {
settings.serviceTier = tier;
} else {
delete settings.serviceTier;
}
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
}
function resolveActiveServiceTier(settingsPath: string): FeynmanServiceTier | undefined {
return normalizeServiceTier(process.env.FEYNMAN_SERVICE_TIER) ?? getConfiguredServiceTier(settingsPath);
}
function resolveProviderServiceTier(
provider: string | undefined,
tier: FeynmanServiceTier | undefined,
): FeynmanServiceTier | undefined {
if (!provider || !tier) return undefined;
if ((provider === "openai" || provider === "openai-codex") && OPENAI_SERVICE_TIERS.has(tier)) {
return tier;
}
if (provider === "anthropic" && ANTHROPIC_SERVICE_TIERS.has(tier)) {
return tier;
}
return undefined;
}
async function selectOption<T>(
ctx: CommandContext,
title: string,
options: SelectOption<T>[],
): Promise<T | undefined> {
const selected = await ctx.ui.select(
title,
options.map((option) => option.label),
);
if (!selected) return undefined;
return options.find((option) => option.label === selected)?.value;
}
function parseRequestedTier(rawArgs: string): FeynmanServiceTier | null | undefined {
const trimmed = rawArgs.trim();
if (!trimmed) return undefined;
if (trimmed === "unset" || trimmed === "clear" || trimmed === "off") return null;
return normalizeServiceTier(trimmed);
}
export function registerServiceTierControls(pi: ExtensionAPI): void {
pi.on("before_provider_request", (event, ctx) => {
if (!ctx.model || !event.payload || typeof event.payload !== "object") {
return;
}
const activeTier = resolveActiveServiceTier(resolveFeynmanSettingsPath());
const providerTier = resolveProviderServiceTier(ctx.model.provider, activeTier);
if (!providerTier) {
return;
}
return {
...(event.payload as Record<string, unknown>),
service_tier: providerTier,
};
});
pi.registerCommand("service-tier", {
description: "View or set the provider service tier override used for supported models.",
handler: async (args, ctx) => {
const settingsPath = resolveFeynmanSettingsPath();
const requested = parseRequestedTier(args);
if (requested === undefined && !args.trim()) {
if (!ctx.hasUI) {
ctx.ui.notify(getConfiguredServiceTier(settingsPath) ?? "not set", "info");
return;
}
const current = getConfiguredServiceTier(settingsPath);
const selected = await selectOption(
ctx,
"Select service tier",
[
{ label: current ? `unset (current: ${current})` : "unset (current)", value: null },
...FEYNMAN_SERVICE_TIERS.map((tier) => ({
label: tier === current ? `${tier} (current)` : tier,
value: tier,
})),
],
);
if (selected === undefined) return;
if (selected === null) {
setConfiguredServiceTier(settingsPath, undefined);
ctx.ui.notify("Cleared service tier override.", "info");
return;
}
setConfiguredServiceTier(settingsPath, selected);
ctx.ui.notify(`Service tier set to ${selected}.`, "info");
return;
}
if (requested === null) {
setConfiguredServiceTier(settingsPath, undefined);
ctx.ui.notify("Cleared service tier override.", "info");
return;
}
if (!requested) {
ctx.ui.notify("Use auto, default, flex, priority, standard_only, or unset.", "error");
return;
}
setConfiguredServiceTier(settingsPath, requested);
ctx.ui.notify(`Service tier set to ${requested}.`, "info");
},
});
}

View File

@@ -35,9 +35,14 @@ export function readPromptSpecs(appRoot) {
}
export const extensionCommandSpecs = [
{ name: "capabilities", args: "", section: "Project & Session", description: "Show installed packages, discovery entrypoints, and runtime capability counts.", publicDocs: true },
{ name: "commands", args: "", section: "Project & Session", description: "Browse all available slash commands, including built-in and package commands.", publicDocs: true },
{ name: "help", args: "", section: "Project & Session", description: "Show grouped Feynman commands and prefill the editor with a selected command.", publicDocs: true },
{ name: "feynman-model", args: "", section: "Project & Session", description: "Open Feynman model menu (main + per-subagent overrides).", publicDocs: true },
{ name: "init", args: "", section: "Project & Session", description: "Bootstrap AGENTS.md and session-log folders for a research project.", publicDocs: true },
{ name: "outputs", args: "", section: "Project & Session", description: "Browse all research artifacts (papers, outputs, experiments, notes).", publicDocs: true },
{ name: "service-tier", args: "", section: "Project & Session", description: "View or set the provider service tier override for supported models.", publicDocs: true },
{ name: "tools", args: "", section: "Project & Session", description: "Browse all callable tools with their source and parameter summary.", publicDocs: true },
];
export const livePackageCommandGroups = [
@@ -57,6 +62,7 @@ export const livePackageCommandGroups = [
{ name: "schedule-prompt", usage: "/schedule-prompt" },
{ name: "search", usage: "/search" },
{ name: "preview", usage: "/preview" },
{ name: "hotkeys", usage: "/hotkeys" },
{ name: "new", usage: "/new" },
{ name: "quit", usage: "/quit" },
{ name: "exit", usage: "/exit" },
@@ -83,6 +89,7 @@ export const cliCommandSections = [
{ usage: "feynman model login [id]", description: "Login to a Pi OAuth model provider." },
{ usage: "feynman model logout [id]", description: "Logout from a Pi OAuth model provider." },
{ usage: "feynman model set <provider/model>", description: "Set the default model." },
{ usage: "feynman model tier [value]", description: "View or set the request service tier override." },
],
},
{
@@ -99,6 +106,8 @@ export const cliCommandSections = [
{ usage: "feynman packages list", description: "Show core and optional Pi package presets." },
{ usage: "feynman packages install <preset>", description: "Install optional package presets on demand." },
{ usage: "feynman search status", description: "Show Pi web-access status and config path." },
{ usage: "feynman search set <provider> [api-key]", description: "Set the web search provider and optionally save its API key." },
{ usage: "feynman search clear", description: "Reset web search provider to auto while preserving API keys." },
{ usage: "feynman update [package]", description: "Update installed packages, or a specific package." },
],
},
@@ -110,6 +119,7 @@ export const legacyFlags = [
{ usage: "--alpha-logout", description: "Clear alphaXiv auth and exit." },
{ usage: "--alpha-status", description: "Show alphaXiv auth status and exit." },
{ usage: "--model <provider:model>", description: "Force a specific model." },
{ usage: "--service-tier <tier>", description: "Override request service tier for this run." },
{ usage: "--thinking <level>", description: "Set thinking level: off | minimal | low | medium | high | xhigh." },
{ usage: "--cwd <path>", description: "Set the working directory for tools." },
{ usage: "--session-dir <path>", description: "Set the session storage directory." },

72
package-lock.json generated
View File

@@ -1,17 +1,18 @@
{
"name": "@companion-ai/feynman",
"version": "0.2.16",
"version": "0.2.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@companion-ai/feynman",
"version": "0.2.16",
"version": "0.2.17",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@companion-ai/alpha-hub": "^0.1.2",
"@mariozechner/pi-ai": "^0.62.0",
"@mariozechner/pi-coding-agent": "^0.62.0",
"@mariozechner/pi-ai": "^0.64.0",
"@mariozechner/pi-coding-agent": "^0.64.0",
"@sinclair/typebox": "^0.34.48",
"dotenv": "^17.3.1"
},
@@ -1264,9 +1265,9 @@
}
},
"node_modules/@hono/node-server": {
"version": "1.19.11",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
"version": "1.19.13",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz",
"integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
@@ -1468,21 +1469,21 @@
}
},
"node_modules/@mariozechner/pi-agent-core": {
"version": "0.62.0",
"resolved": "https://registry.npmjs.org/@mariozechner/pi-agent-core/-/pi-agent-core-0.62.0.tgz",
"integrity": "sha512-SBjqgDrgKOaC+IGzFGB3jXQErv9H1QMYnWFvUg6ra6dG0ZgWFBUZb6unidngWLsmaxSDWes6KeKiVFMsr2VSEQ==",
"version": "0.64.0",
"resolved": "https://registry.npmjs.org/@mariozechner/pi-agent-core/-/pi-agent-core-0.64.0.tgz",
"integrity": "sha512-IN/sIxWOD0v1OFVXHB605SGiZhO5XdEWG5dO8EAV08n3jz/p12o4OuYGvhGXmHhU28WXa/FGWC+FO5xiIih8Uw==",
"license": "MIT",
"dependencies": {
"@mariozechner/pi-ai": "^0.62.0"
"@mariozechner/pi-ai": "^0.64.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@mariozechner/pi-ai": {
"version": "0.62.0",
"resolved": "https://registry.npmjs.org/@mariozechner/pi-ai/-/pi-ai-0.62.0.tgz",
"integrity": "sha512-mJgryZ5RgBQG++tiETMtCQQJoH2MAhKetCfqI98NMvGydu7L9x2qC2JekQlRaAgIlTgv4MRH1UXHMEs4UweE/Q==",
"version": "0.64.0",
"resolved": "https://registry.npmjs.org/@mariozechner/pi-ai/-/pi-ai-0.64.0.tgz",
"integrity": "sha512-Z/Jnf+JSVDPLRcxJsa8XhYTJKIqKekNueaCpBLGQHgizL1F9RQ1Rur3rIfZpfXkt2cLu/AIPtOs223ueuoWaWg==",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "^0.73.0",
@@ -1507,16 +1508,17 @@
}
},
"node_modules/@mariozechner/pi-coding-agent": {
"version": "0.62.0",
"resolved": "https://registry.npmjs.org/@mariozechner/pi-coding-agent/-/pi-coding-agent-0.62.0.tgz",
"integrity": "sha512-f1NnExqsHuA6w8UVlBtPsvTBhdkMc0h1JD9SzGCdWTLou5GHJr2JIP6DlwV9IKWAnM+sAelaoFez+14wLP2zOQ==",
"version": "0.64.0",
"resolved": "https://registry.npmjs.org/@mariozechner/pi-coding-agent/-/pi-coding-agent-0.64.0.tgz",
"integrity": "sha512-Q4tcqSqFGQtOgCtRyIp1D80Nv2if13Q2pfbnrOlaT/mix90mLcZGML9jKVnT1jGSy5GMYudU1HsS7cx53kxb0g==",
"license": "MIT",
"dependencies": {
"@mariozechner/jiti": "^2.6.2",
"@mariozechner/pi-agent-core": "^0.62.0",
"@mariozechner/pi-ai": "^0.62.0",
"@mariozechner/pi-tui": "^0.62.0",
"@mariozechner/pi-agent-core": "^0.64.0",
"@mariozechner/pi-ai": "^0.64.0",
"@mariozechner/pi-tui": "^0.64.0",
"@silvia-odwyer/photon-node": "^0.3.4",
"ajv": "^8.17.1",
"chalk": "^5.5.0",
"cli-highlight": "^2.1.11",
"diff": "^8.0.2",
@@ -1543,9 +1545,9 @@
}
},
"node_modules/@mariozechner/pi-tui": {
"version": "0.62.0",
"resolved": "https://registry.npmjs.org/@mariozechner/pi-tui/-/pi-tui-0.62.0.tgz",
"integrity": "sha512-/At11PPe8l319MnUoK4wN5L/uVCU6bDdiIUzH8Ez0stOkjSF6isRXScZ+RMM+6iCKsD4muBTX8Cmcif+3/UWHA==",
"version": "0.64.0",
"resolved": "https://registry.npmjs.org/@mariozechner/pi-tui/-/pi-tui-0.64.0.tgz",
"integrity": "sha512-W1qLry9MAuN/V3YJmMv/BJa0VaYv721NkXPg/DGItdqWxuDc+1VdNbyAnRwxblNkIpXVUWL26x64BlyFXpxmkg==",
"license": "MIT",
"dependencies": {
"@types/mime-types": "^2.1.4",
@@ -2528,9 +2530,9 @@
"license": "MIT"
},
"node_modules/basic-ftp": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz",
"integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==",
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.1.tgz",
"integrity": "sha512-0yaL8JdxTknKDILitVpfYfV2Ob6yb3udX/hK97M7I3jOeznBNxQPtVvTUtnhUkyHlxFWyr5Lvknmgzoc7jf+1Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -2576,9 +2578,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
@@ -3621,9 +3623,9 @@
}
},
"node_modules/hono": {
"version": "4.12.9",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz",
"integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==",
"version": "4.12.12",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz",
"integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -4216,9 +4218,9 @@
}
},
"node_modules/path-to-regexp": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
"funding": {
"type": "opencollective",

View File

@@ -1,6 +1,6 @@
{
"name": "@companion-ai/feynman",
"version": "0.2.16",
"version": "0.2.17",
"description": "Research-first CLI agent built on Pi and alphaXiv",
"license": "MIT",
"type": "module",
@@ -60,11 +60,33 @@
},
"dependencies": {
"@companion-ai/alpha-hub": "^0.1.2",
"@mariozechner/pi-ai": "^0.62.0",
"@mariozechner/pi-coding-agent": "^0.62.0",
"@mariozechner/pi-ai": "^0.64.0",
"@mariozechner/pi-coding-agent": "^0.64.0",
"@sinclair/typebox": "^0.34.48",
"dotenv": "^17.3.1"
},
"overrides": {
"basic-ftp": "5.2.1",
"@modelcontextprotocol/sdk": {
"@hono/node-server": "1.19.13",
"hono": "4.12.12"
},
"express": {
"router": {
"path-to-regexp": "8.4.2"
}
},
"proxy-agent": {
"pac-proxy-agent": {
"get-uri": {
"basic-ftp": "5.2.1"
}
}
},
"minimatch": {
"brace-expansion": "5.0.5"
}
},
"devDependencies": {
"@types/node": "^25.5.0",
"tsx": "^4.21.0",

View File

@@ -275,7 +275,8 @@ function writeLauncher(bundleRoot, target) {
"@echo off",
"setlocal",
'set "ROOT=%~dp0"',
'"%ROOT%node\\node.exe" "%ROOT%app\\bin\\feynman.js" %*',
'if "%ROOT:~-1%"=="\\" set "ROOT=%ROOT:~0,-1%"',
'"%ROOT%\\node\\node.exe" "%ROOT%\\app\\bin\\feynman.js" %*',
"",
].join("\r\n"),
"utf8",

View File

@@ -46,7 +46,7 @@ function Resolve-VersionMetadata {
return [PSCustomObject]@{
ResolvedVersion = $resolvedVersion
GitRef = "v$resolvedVersion"
DownloadUrl = "https://github.com/getcompanion-ai/feynman/archive/refs/tags/v$resolvedVersion.zip"
DownloadUrl = if ($env:FEYNMAN_INSTALL_SKILLS_ARCHIVE_URL) { $env:FEYNMAN_INSTALL_SKILLS_ARCHIVE_URL } else { "https://github.com/getcompanion-ai/feynman/archive/refs/tags/v$resolvedVersion.zip" }
}
}
@@ -92,8 +92,9 @@ try {
}
$skillsSource = Join-Path $sourceRoot.FullName "skills"
if (-not (Test-Path $skillsSource)) {
throw "Could not find skills/ in downloaded archive."
$promptsSource = Join-Path $sourceRoot.FullName "prompts"
if (-not (Test-Path $skillsSource) -or -not (Test-Path $promptsSource)) {
throw "Could not find the bundled skills resources in the downloaded archive."
}
$installParent = Split-Path $installDir -Parent
@@ -107,6 +108,10 @@ try {
New-Item -ItemType Directory -Path $installDir -Force | Out-Null
Copy-Item -Path (Join-Path $skillsSource "*") -Destination $installDir -Recurse -Force
New-Item -ItemType Directory -Path (Join-Path $installDir "prompts") -Force | Out-Null
Copy-Item -Path (Join-Path $promptsSource "*") -Destination (Join-Path $installDir "prompts") -Recurse -Force
Copy-Item -Path (Join-Path $sourceRoot.FullName "AGENTS.md") -Destination (Join-Path $installDir "AGENTS.md") -Force
Copy-Item -Path (Join-Path $sourceRoot.FullName "CONTRIBUTING.md") -Destination (Join-Path $installDir "CONTRIBUTING.md") -Force
Write-Host "==> Installed skills to $installDir"
if ($Scope -eq "Repo") {

View File

@@ -146,15 +146,17 @@ archive_metadata="$(resolve_version)"
resolved_version="$(printf '%s\n' "$archive_metadata" | sed -n '1p')"
git_ref="$(printf '%s\n' "$archive_metadata" | sed -n '2p')"
archive_url=""
case "$git_ref" in
archive_url="${FEYNMAN_INSTALL_SKILLS_ARCHIVE_URL:-}"
if [ -z "$archive_url" ]; then
case "$git_ref" in
main)
archive_url="https://github.com/getcompanion-ai/feynman/archive/refs/heads/main.tar.gz"
;;
v*)
archive_url="https://github.com/getcompanion-ai/feynman/archive/refs/tags/${git_ref}.tar.gz"
;;
esac
esac
fi
if [ -z "$archive_url" ]; then
echo "Could not resolve a download URL for ref: $git_ref" >&2
@@ -181,8 +183,8 @@ step "Extracting skills"
tar -xzf "$archive_path" -C "$extract_dir"
source_root="$(find "$extract_dir" -mindepth 1 -maxdepth 1 -type d | head -n 1)"
if [ -z "$source_root" ] || [ ! -d "$source_root/skills" ]; then
echo "Could not find skills/ in downloaded archive." >&2
if [ -z "$source_root" ] || [ ! -d "$source_root/skills" ] || [ ! -d "$source_root/prompts" ]; then
echo "Could not find the bundled skills resources in the downloaded archive." >&2
exit 1
fi
@@ -190,6 +192,10 @@ mkdir -p "$(dirname "$install_dir")"
rm -rf "$install_dir"
mkdir -p "$install_dir"
cp -R "$source_root/skills/." "$install_dir/"
mkdir -p "$install_dir/prompts"
cp -R "$source_root/prompts/." "$install_dir/prompts/"
cp "$source_root/AGENTS.md" "$install_dir/AGENTS.md"
cp "$source_root/CONTRIBUTING.md" "$install_dir/CONTRIBUTING.md"
step "Installed skills to $install_dir"
case "$SCOPE" in

View File

@@ -109,8 +109,8 @@ This usually means the release exists, but not all platform bundles were uploade
Workarounds:
- try again after the release finishes publishing
- install via pnpm instead: pnpm add -g @companion-ai/feynman
- install via bun instead: bun add -g @companion-ai/feynman
- pass the latest published version explicitly, e.g.:
& ([scriptblock]::Create((irm https://feynman.is/install.ps1))) -Version 0.2.16
"@
}
@@ -125,12 +125,18 @@ Workarounds:
New-Item -ItemType Directory -Path $installBinDir -Force | Out-Null
$shimPath = Join-Path $installBinDir "feynman.cmd"
$shimPs1Path = Join-Path $installBinDir "feynman.ps1"
Write-Host "==> Linking feynman into $installBinDir"
@"
@echo off
"$bundleDir\feynman.cmd" %*
CALL "$bundleDir\feynman.cmd" %*
"@ | Set-Content -Path $shimPath -Encoding ASCII
@"
`$BundleDir = "$bundleDir"
& "`$BundleDir\node\node.exe" "`$BundleDir\app\bin\feynman.js" @args
"@ | Set-Content -Path $shimPs1Path -Encoding UTF8
$currentUserPath = [Environment]::GetEnvironmentVariable("Path", "User")
$alreadyOnPath = $false
if ($currentUserPath) {
@@ -153,9 +159,7 @@ Workarounds:
Write-Warning "Current shell resolves feynman to $($resolvedCommand.Source)"
Write-Host "Run in a new shell, or run: `$env:Path = '$installBinDir;' + `$env:Path"
Write-Host "Then run: feynman"
if ($resolvedCommand.Source -like "*node_modules*@companion-ai*feynman*") {
Write-Host "If that path is an old global npm install, remove it with: npm uninstall -g @companion-ai/feynman"
}
Write-Host "If that path is an old package-manager install, remove it or put $installBinDir first on PATH."
}
Write-Host "Feynman $resolvedVersion installed successfully."

View File

@@ -177,11 +177,7 @@ warn_command_conflict() {
step "Run now: export PATH=\"$INSTALL_BIN_DIR:\$PATH\" && hash -r && feynman"
step "Or launch directly: $expected_path"
case "$resolved_path" in
*"/node_modules/@companion-ai/feynman/"* | *"/node_modules/.bin/feynman")
step "If that path is an old global npm install, remove it with: npm uninstall -g @companion-ai/feynman"
;;
esac
step "If that path is an old package-manager install, remove it or put $INSTALL_BIN_DIR first on PATH."
fi
}
@@ -264,8 +260,8 @@ This usually means the release exists, but not all platform bundles were uploade
Workarounds:
- try again after the release finishes publishing
- install via pnpm instead: pnpm add -g @companion-ai/feynman
- install via bun instead: bun add -g @companion-ai/feynman
- pass the latest published version explicitly, e.g.:
curl -fsSL https://feynman.is/install | bash -s -- 0.2.16
EOF
exit 1
fi

View File

@@ -0,0 +1 @@
export function patchPiExtensionLoaderSource(source: string): string;

View File

@@ -0,0 +1,32 @@
const PATH_TO_FILE_URL_IMPORT = 'import { fileURLToPath, pathToFileURL } from "node:url";';
const FILE_URL_TO_PATH_IMPORT = 'import { fileURLToPath } from "node:url";';
const IMPORT_CALL = 'const module = await jiti.import(extensionPath, { default: true });';
const PATCHED_IMPORT_CALL = [
' const extensionSpecifier = process.platform === "win32" && path.isAbsolute(extensionPath)',
' ? pathToFileURL(extensionPath).href',
' : extensionPath;',
' const module = await jiti.import(extensionSpecifier, { default: true });',
].join("\n");
export function patchPiExtensionLoaderSource(source) {
let patched = source;
if (patched.includes(PATH_TO_FILE_URL_IMPORT) || patched.includes(PATCHED_IMPORT_CALL)) {
return patched;
}
if (patched.includes(FILE_URL_TO_PATH_IMPORT)) {
patched = patched.replace(FILE_URL_TO_PATH_IMPORT, PATH_TO_FILE_URL_IMPORT);
}
if (!patched.includes(PATH_TO_FILE_URL_IMPORT)) {
return source;
}
if (!patched.includes(IMPORT_CALL)) {
return source;
}
return patched.replace(IMPORT_CALL, PATCHED_IMPORT_CALL);
}

View File

@@ -0,0 +1 @@
export function patchPiGoogleLegacySchemaSource(source: string): string;

View File

@@ -0,0 +1,44 @@
const HELPER = [
"function normalizeLegacyToolSchema(schema) {",
" if (Array.isArray(schema)) return schema.map((item) => normalizeLegacyToolSchema(item));",
' if (!schema || typeof schema !== "object") return schema;',
" const normalized = {};",
" for (const [key, value] of Object.entries(schema)) {",
' if (key === "const") {',
" normalized.enum = [value];",
" continue;",
" }",
" normalized[key] = normalizeLegacyToolSchema(value);",
" }",
" return normalized;",
"}",
].join("\n");
const ORIGINAL =
' ...(useParameters ? { parameters: tool.parameters } : { parametersJsonSchema: tool.parameters }),';
const PATCHED = [
" ...(useParameters",
" ? { parameters: normalizeLegacyToolSchema(tool.parameters) }",
" : { parametersJsonSchema: tool.parameters }),",
].join("\n");
export function patchPiGoogleLegacySchemaSource(source) {
let patched = source;
if (patched.includes("function normalizeLegacyToolSchema(schema) {")) {
return patched;
}
if (!patched.includes(ORIGINAL)) {
return source;
}
patched = patched.replace(ORIGINAL, PATCHED);
const marker = "export function convertTools(tools, useParameters = false) {";
const markerIndex = patched.indexOf(marker);
if (markerIndex === -1) {
return source;
}
return `${patched.slice(0, markerIndex)}${HELPER}\n\n${patched.slice(markerIndex)}`;
}

View File

@@ -0,0 +1,2 @@
export const PI_WEB_ACCESS_PATCH_TARGETS: string[];
export function patchPiWebAccessSource(relativePath: string, source: string): string;

View File

@@ -0,0 +1,32 @@
export const PI_WEB_ACCESS_PATCH_TARGETS = [
"index.ts",
"exa.ts",
"gemini-api.ts",
"gemini-search.ts",
"gemini-web.ts",
"github-extract.ts",
"perplexity.ts",
"video-extract.ts",
"youtube-extract.ts",
];
const LEGACY_CONFIG_EXPR = 'join(homedir(), ".pi", "web-search.json")';
const PATCHED_CONFIG_EXPR =
'process.env.FEYNMAN_WEB_SEARCH_CONFIG ?? process.env.PI_WEB_SEARCH_CONFIG ?? join(homedir(), ".pi", "web-search.json")';
export function patchPiWebAccessSource(relativePath, source) {
let patched = source;
if (patched.includes(PATCHED_CONFIG_EXPR)) {
return patched;
}
patched = patched.split(LEGACY_CONFIG_EXPR).join(PATCHED_CONFIG_EXPR);
if (relativePath === "index.ts" && patched !== source) {
patched = patched.replace('import { join } from "node:path";', 'import { dirname, join } from "node:path";');
patched = patched.replace('const dir = join(homedir(), ".pi");', "const dir = dirname(WEB_SEARCH_CONFIG_PATH);");
}
return patched;
}

View File

@@ -1,13 +1,22 @@
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { homedir } from "node:os";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { FEYNMAN_LOGO_HTML } from "../logo.mjs";
import { patchPiExtensionLoaderSource } from "./lib/pi-extension-loader-patch.mjs";
import { patchPiGoogleLegacySchemaSource } from "./lib/pi-google-legacy-schema-patch.mjs";
import { PI_WEB_ACCESS_PATCH_TARGETS, patchPiWebAccessSource } from "./lib/pi-web-access-patch.mjs";
import { PI_SUBAGENTS_PATCH_TARGETS, patchPiSubagentsSource } from "./lib/pi-subagents-patch.mjs";
const here = dirname(fileURLToPath(import.meta.url));
const appRoot = resolve(here, "..");
const feynmanHome = resolve(process.env.FEYNMAN_HOME ?? homedir(), ".feynman");
const feynmanNpmPrefix = resolve(feynmanHome, "npm-global");
process.env.FEYNMAN_NPM_PREFIX = feynmanNpmPrefix;
process.env.NPM_CONFIG_PREFIX = feynmanNpmPrefix;
process.env.npm_config_prefix = feynmanNpmPrefix;
const appRequire = createRequire(resolve(appRoot, "package.json"));
const isGlobalInstall = process.env.npm_config_global === "true" || process.env.npm_config_location === "global";
@@ -52,9 +61,19 @@ const cliPath = piPackageRoot ? resolve(piPackageRoot, "dist", "cli.js") : null;
const bunCliPath = piPackageRoot ? resolve(piPackageRoot, "dist", "bun", "cli.js") : null;
const interactiveModePath = piPackageRoot ? resolve(piPackageRoot, "dist", "modes", "interactive", "interactive-mode.js") : null;
const interactiveThemePath = piPackageRoot ? resolve(piPackageRoot, "dist", "modes", "interactive", "theme", "theme.js") : null;
const extensionLoaderPath = piPackageRoot ? resolve(piPackageRoot, "dist", "core", "extensions", "loader.js") : null;
const terminalPath = piTuiRoot ? resolve(piTuiRoot, "dist", "terminal.js") : null;
const editorPath = piTuiRoot ? resolve(piTuiRoot, "dist", "components", "editor.js") : null;
const workspaceRoot = resolve(appRoot, ".feynman", "npm", "node_modules");
const workspaceExtensionLoaderPath = resolve(
workspaceRoot,
"@mariozechner",
"pi-coding-agent",
"dist",
"core",
"extensions",
"loader.js",
);
const piSubagentsRoot = resolve(workspaceRoot, "pi-subagents");
const webAccessPath = resolve(workspaceRoot, "pi-web-access", "index.ts");
const sessionSearchIndexerPath = resolve(
@@ -73,7 +92,17 @@ const workspaceArchivePath = resolve(appRoot, ".feynman", "runtime-workspace.tgz
function createInstallCommand(packageManager, packageSpecs) {
switch (packageManager) {
case "npm":
return ["install", "--prefer-offline", "--no-audit", "--no-fund", "--loglevel", "error", ...packageSpecs];
return [
"install",
"--global=false",
"--location=project",
"--prefer-offline",
"--no-audit",
"--no-fund",
"--loglevel",
"error",
...packageSpecs,
];
case "pnpm":
return ["add", "--prefer-offline", "--reporter", "silent", ...packageSpecs];
case "bun":
@@ -352,6 +381,18 @@ if (interactiveModePath && existsSync(interactiveModePath)) {
}
}
for (const loaderPath of [extensionLoaderPath, workspaceExtensionLoaderPath].filter(Boolean)) {
if (!existsSync(loaderPath)) {
continue;
}
const source = readFileSync(loaderPath, "utf8");
const patched = patchPiExtensionLoaderSource(source);
if (patched !== source) {
writeFileSync(loaderPath, patched, "utf8");
}
}
if (interactiveThemePath && existsSync(interactiveThemePath)) {
let themeSource = readFileSync(interactiveThemePath, "utf8");
const desiredGetEditorTheme = [
@@ -527,6 +568,21 @@ if (existsSync(webAccessPath)) {
}
}
const piWebAccessRoot = resolve(workspaceRoot, "pi-web-access");
if (existsSync(piWebAccessRoot)) {
for (const relativePath of PI_WEB_ACCESS_PATCH_TARGETS) {
const entryPath = resolve(piWebAccessRoot, relativePath);
if (!existsSync(entryPath)) continue;
const source = readFileSync(entryPath, "utf8");
const patched = patchPiWebAccessSource(relativePath, source);
if (patched !== source) {
writeFileSync(entryPath, patched, "utf8");
}
}
}
if (existsSync(sessionSearchIndexerPath)) {
const source = readFileSync(sessionSearchIndexerPath, "utf8");
const original = 'const sessionsDir = path.join(os.homedir(), ".pi", "agent", "sessions");';
@@ -538,6 +594,7 @@ if (existsSync(sessionSearchIndexerPath)) {
}
const oauthPagePath = piAiRoot ? resolve(piAiRoot, "dist", "utils", "oauth", "oauth-page.js") : null;
const googleSharedPath = piAiRoot ? resolve(piAiRoot, "dist", "providers", "google-shared.js") : null;
if (oauthPagePath && existsSync(oauthPagePath)) {
let source = readFileSync(oauthPagePath, "utf8");
@@ -550,6 +607,14 @@ if (oauthPagePath && existsSync(oauthPagePath)) {
if (changed) writeFileSync(oauthPagePath, source, "utf8");
}
if (googleSharedPath && existsSync(googleSharedPath)) {
const source = readFileSync(googleSharedPath, "utf8");
const patched = patchPiGoogleLegacySchemaSource(source);
if (patched !== source) {
writeFileSync(googleSharedPath, patched, "utf8");
}
}
const alphaHubAuthPath = findPackageRoot("@companion-ai/alpha-hub")
? resolve(findPackageRoot("@companion-ai/alpha-hub"), "src", "lib", "auth.js")
: null;

View File

@@ -5,7 +5,7 @@ description: Autonomous experiment loop that tries ideas, measures results, keep
# Autoresearch
Run the `/autoresearch` workflow. Read the prompt template at `prompts/autoresearch.md` for the full procedure.
Run the `/autoresearch` workflow. Read the prompt template at `../prompts/autoresearch.md` for the full procedure.
Tools used: `init_experiment`, `run_experiment`, `log_experiment` (from pi-autoresearch)

View File

@@ -5,7 +5,7 @@ description: Contribute changes to the Feynman repository itself. Use when the t
# Contributing
Read `CONTRIBUTING.md` first, then `AGENTS.md` for repo-level agent conventions.
Read `../CONTRIBUTING.md` first, then `../AGENTS.md` for repo-level agent conventions.
Use this skill when working on Feynman itself, especially for:

View File

@@ -5,7 +5,7 @@ description: Run a thorough, source-heavy investigation on any topic. Use when t
# Deep Research
Run the `/deepresearch` workflow. Read the prompt template at `prompts/deepresearch.md` for the full procedure.
Run the `/deepresearch` workflow. Read the prompt template at `../prompts/deepresearch.md` for the full procedure.
Agents used: `researcher`, `verifier`, `reviewer`

View File

@@ -5,6 +5,6 @@ description: Inspect active background research work including running processes
# Jobs
Run the `/jobs` workflow. Read the prompt template at `prompts/jobs.md` for the full procedure.
Run the `/jobs` workflow. Read the prompt template at `../prompts/jobs.md` for the full procedure.
Shows active `pi-processes`, scheduled `pi-schedule-prompt` entries, and running subagent tasks.

View File

@@ -5,7 +5,7 @@ description: Run a literature review using paper search and primary-source synth
# Literature Review
Run the `/lit` workflow. Read the prompt template at `prompts/lit.md` for the full procedure.
Run the `/lit` workflow. Read the prompt template at `../prompts/lit.md` for the full procedure.
Agents used: `researcher`, `verifier`, `reviewer`

View File

@@ -5,7 +5,7 @@ description: Compare a paper's claims against its public codebase. Use when the
# Paper-Code Audit
Run the `/audit` workflow. Read the prompt template at `prompts/audit.md` for the full procedure.
Run the `/audit` workflow. Read the prompt template at `../prompts/audit.md` for the full procedure.
Agents used: `researcher`, `verifier`

View File

@@ -5,7 +5,7 @@ description: Turn research findings into a polished paper-style draft with secti
# Paper Writing
Run the `/draft` workflow. Read the prompt template at `prompts/draft.md` for the full procedure.
Run the `/draft` workflow. Read the prompt template at `../prompts/draft.md` for the full procedure.
Agents used: `writer`, `verifier`

View File

@@ -5,7 +5,7 @@ description: Simulate a tough but constructive peer review of an AI research art
# Peer Review
Run the `/review` workflow. Read the prompt template at `prompts/review.md` for the full procedure.
Run the `/review` workflow. Read the prompt template at `../prompts/review.md` for the full procedure.
Agents used: `researcher`, `reviewer`

View File

@@ -5,7 +5,7 @@ description: Plan or execute a replication of a paper, claim, or benchmark. Use
# Replication
Run the `/replicate` workflow. Read the prompt template at `prompts/replicate.md` for the full procedure.
Run the `/replicate` workflow. Read the prompt template at `../prompts/replicate.md` for the full procedure.
Agents used: `researcher`

View File

@@ -5,6 +5,6 @@ description: Write a durable session log capturing completed work, findings, ope
# Session Log
Run the `/log` workflow. Read the prompt template at `prompts/log.md` for the full procedure.
Run the `/log` workflow. Read the prompt template at `../prompts/log.md` for the full procedure.
Output: session log in `notes/session-logs/`.

View File

@@ -5,7 +5,7 @@ description: Compare multiple sources on a topic and produce a grounded comparis
# Source Comparison
Run the `/compare` workflow. Read the prompt template at `prompts/compare.md` for the full procedure.
Run the `/compare` workflow. Read the prompt template at `../prompts/compare.md` for the full procedure.
Agents used: `researcher`, `verifier`

View File

@@ -5,7 +5,7 @@ description: Set up a recurring research watch on a topic, company, paper area,
# Watch
Run the `/watch` workflow. Read the prompt template at `prompts/watch.md` for the full procedure.
Run the `/watch` workflow. Read the prompt template at `../prompts/watch.md` for the full procedure.
Agents used: `researcher`

View File

@@ -18,6 +18,8 @@ import { ensureFeynmanHome, getDefaultSessionDir, getFeynmanAgentDir, getFeynman
import { launchPiChat } from "./pi/launch.js";
import { CORE_PACKAGE_SOURCES, getOptionalPackagePresetSources, listOptionalPackagePresets } from "./pi/package-presets.js";
import { normalizeFeynmanSettings, normalizeThinkingLevel, parseModelSpec } from "./pi/settings.js";
import { applyFeynmanPackageManagerEnv } from "./pi/runtime.js";
import { getConfiguredServiceTier, normalizeServiceTier, setConfiguredServiceTier } from "./model/service-tier.js";
import {
authenticateModelProvider,
getCurrentModelSpec,
@@ -26,7 +28,8 @@ import {
printModelList,
setDefaultModelSpec,
} from "./model/commands.js";
import { printSearchStatus } from "./search/commands.js";
import { clearSearchConfig, printSearchStatus, setSearchProvider } from "./search/commands.js";
import type { PiWebSearchProvider } from "./pi/web-access.js";
import { runDoctor, runStatus } from "./setup/doctor.js";
import { setupPreviewDependencies } from "./setup/preview.js";
import { runSetup } from "./setup/setup.js";
@@ -150,10 +153,34 @@ async function handleModelCommand(subcommand: string | undefined, args: string[]
return;
}
if (subcommand === "tier") {
const requested = args[0];
if (!requested) {
console.log(getConfiguredServiceTier(feynmanSettingsPath) ?? "not set");
return;
}
if (requested === "unset" || requested === "clear" || requested === "off") {
setConfiguredServiceTier(feynmanSettingsPath, undefined);
console.log("Cleared service tier override");
return;
}
const tier = normalizeServiceTier(requested);
if (!tier) {
throw new Error("Usage: feynman model tier <auto|default|flex|priority|standard_only|unset>");
}
setConfiguredServiceTier(feynmanSettingsPath, tier);
console.log(`Service tier set to ${tier}`);
return;
}
throw new Error(`Unknown model command: ${subcommand}`);
}
async function handleUpdateCommand(workingDir: string, feynmanAgentDir: string, source?: string): Promise<void> {
applyFeynmanPackageManagerEnv(feynmanAgentDir);
const settingsManager = SettingsManager.create(workingDir, feynmanAgentDir);
const packageManager = new DefaultPackageManager({
cwd: workingDir,
@@ -177,6 +204,7 @@ async function handleUpdateCommand(workingDir: string, feynmanAgentDir: string,
}
async function handlePackagesCommand(subcommand: string | undefined, args: string[], workingDir: string, feynmanAgentDir: string): Promise<void> {
applyFeynmanPackageManagerEnv(feynmanAgentDir);
const settingsManager = SettingsManager.create(workingDir, feynmanAgentDir);
const configuredSources = new Set(
settingsManager
@@ -242,12 +270,27 @@ async function handlePackagesCommand(subcommand: string | undefined, args: strin
console.log("Optional packages installed.");
}
function handleSearchCommand(subcommand: string | undefined): void {
function handleSearchCommand(subcommand: string | undefined, args: string[]): void {
if (!subcommand || subcommand === "status") {
printSearchStatus();
return;
}
if (subcommand === "set") {
const provider = args[0] as PiWebSearchProvider | undefined;
const validProviders: PiWebSearchProvider[] = ["auto", "perplexity", "exa", "gemini"];
if (!provider || !validProviders.includes(provider)) {
throw new Error("Usage: feynman search set <auto|perplexity|exa|gemini> [api-key]");
}
setSearchProvider(provider, args[1]);
return;
}
if (subcommand === "clear") {
clearSearchConfig();
return;
}
throw new Error(`Unknown search command: ${subcommand}`);
}
@@ -305,9 +348,11 @@ export async function main(): Promise<void> {
"alpha-login": { type: "boolean" },
"alpha-logout": { type: "boolean" },
"alpha-status": { type: "boolean" },
mode: { type: "string" },
model: { type: "string" },
"new-session": { type: "boolean" },
prompt: { type: "string" },
"service-tier": { type: "string" },
"session-dir": { type: "string" },
"setup-preview": { type: "boolean" },
thinking: { type: "string" },
@@ -414,7 +459,7 @@ export async function main(): Promise<void> {
}
if (command === "search") {
handleSearchCommand(rest[0]);
handleSearchCommand(rest[0], rest.slice(1));
return;
}
@@ -434,6 +479,17 @@ export async function main(): Promise<void> {
}
const explicitModelSpec = values.model ?? process.env.FEYNMAN_MODEL;
const explicitServiceTier = normalizeServiceTier(values["service-tier"] ?? process.env.FEYNMAN_SERVICE_TIER);
const mode = values.mode;
if (mode !== undefined && mode !== "text" && mode !== "json" && mode !== "rpc") {
throw new Error("Unknown mode. Use text, json, or rpc.");
}
if ((values["service-tier"] ?? process.env.FEYNMAN_SERVICE_TIER) && !explicitServiceTier) {
throw new Error("Unknown service tier. Use auto, default, flex, priority, or standard_only.");
}
if (explicitServiceTier) {
process.env.FEYNMAN_SERVICE_TIER = explicitServiceTier;
}
if (explicitModelSpec) {
const modelRegistry = createModelRegistry(feynmanAuthPath);
const explicitModel = parseModelSpec(explicitModelSpec, modelRegistry);
@@ -464,6 +520,7 @@ export async function main(): Promise<void> {
sessionDir,
feynmanAgentDir,
feynmanVersion,
mode,
thinkingLevel,
explicitModelSpec,
oneShotPrompt: values.prompt,

View File

@@ -95,6 +95,14 @@ const RESEARCH_MODEL_PREFERENCES = [
spec: "zai/glm-5",
reason: "good fallback when GLM is the available research model",
},
{
spec: "minimax/minimax-m2.7",
reason: "good fallback when MiniMax is the available research model",
},
{
spec: "minimax/minimax-m2.7-highspeed",
reason: "good fallback when MiniMax is the available research model",
},
{
spec: "kimi-coding/kimi-k2-thinking",
reason: "good fallback when Kimi is the available research model",

View File

@@ -7,6 +7,5 @@ export function getModelsJsonPath(authPath: string): string {
}
export function createModelRegistry(authPath: string): ModelRegistry {
return new ModelRegistry(AuthStorage.create(authPath), getModelsJsonPath(authPath));
return ModelRegistry.create(AuthStorage.create(authPath), getModelsJsonPath(authPath));
}

65
src/model/service-tier.ts Normal file
View File

@@ -0,0 +1,65 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
export const FEYNMAN_SERVICE_TIERS = [
"auto",
"default",
"flex",
"priority",
"standard_only",
] as const;
export type FeynmanServiceTier = (typeof FEYNMAN_SERVICE_TIERS)[number];
const SERVICE_TIER_SET = new Set<string>(FEYNMAN_SERVICE_TIERS);
const OPENAI_SERVICE_TIERS = new Set<FeynmanServiceTier>(["auto", "default", "flex", "priority"]);
const ANTHROPIC_SERVICE_TIERS = new Set<FeynmanServiceTier>(["auto", "standard_only"]);
function readSettings(settingsPath: string): Record<string, unknown> {
try {
return JSON.parse(readFileSync(settingsPath, "utf8")) as Record<string, unknown>;
} catch {
return {};
}
}
export function normalizeServiceTier(value: string | undefined): FeynmanServiceTier | undefined {
if (!value) return undefined;
const normalized = value.trim().toLowerCase();
return SERVICE_TIER_SET.has(normalized) ? (normalized as FeynmanServiceTier) : undefined;
}
export function getConfiguredServiceTier(settingsPath: string): FeynmanServiceTier | undefined {
const settings = readSettings(settingsPath);
return normalizeServiceTier(typeof settings.serviceTier === "string" ? settings.serviceTier : undefined);
}
export function setConfiguredServiceTier(settingsPath: string, tier: FeynmanServiceTier | undefined): void {
const settings = readSettings(settingsPath);
if (tier) {
settings.serviceTier = tier;
} else {
delete settings.serviceTier;
}
mkdirSync(dirname(settingsPath), { recursive: true });
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
}
export function resolveActiveServiceTier(settingsPath: string): FeynmanServiceTier | undefined {
return normalizeServiceTier(process.env.FEYNMAN_SERVICE_TIER) ?? getConfiguredServiceTier(settingsPath);
}
export function resolveProviderServiceTier(
provider: string | undefined,
tier: FeynmanServiceTier | undefined,
): FeynmanServiceTier | undefined {
if (!provider || !tier) return undefined;
if ((provider === "openai" || provider === "openai-codex") && OPENAI_SERVICE_TIERS.has(tier)) {
return tier;
}
if (provider === "anthropic" && ANTHROPIC_SERVICE_TIERS.has(tier)) {
return tier;
}
return undefined;
}

View File

@@ -1,9 +1,15 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { constants } from "node:os";
import { buildPiArgs, buildPiEnv, type PiRuntimeOptions, resolvePiPaths } from "./runtime.js";
import { buildPiArgs, buildPiEnv, type PiRuntimeOptions, resolvePiPaths, toNodeImportSpecifier } from "./runtime.js";
import { ensureSupportedNodeVersion } from "../system/node-version.js";
export function exitCodeFromSignal(signal: NodeJS.Signals): number {
const signalNumber = constants.signals[signal];
return typeof signalNumber === "number" ? 128 + signalNumber : 1;
}
export async function launchPiChat(options: PiRuntimeOptions): Promise<void> {
ensureSupportedNodeVersion();
@@ -18,13 +24,13 @@ export async function launchPiChat(options: PiRuntimeOptions): Promise<void> {
throw new Error(`Promise polyfill not found: ${promisePolyfillPath}`);
}
if (process.stdout.isTTY) {
if (process.stdout.isTTY && options.mode !== "rpc") {
process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
}
const importArgs = useDevPolyfill
? ["--import", tsxLoaderPath, "--import", promisePolyfillSourcePath]
: ["--import", promisePolyfillPath];
? ["--import", toNodeImportSpecifier(tsxLoaderPath), "--import", toNodeImportSpecifier(promisePolyfillSourcePath)]
: ["--import", toNodeImportSpecifier(promisePolyfillPath)];
const child = spawn(process.execPath, [...importArgs, piCliPath, ...buildPiArgs(options)], {
cwd: options.workingDir,
@@ -36,11 +42,9 @@ export async function launchPiChat(options: PiRuntimeOptions): Promise<void> {
child.on("error", reject);
child.on("exit", (code, signal) => {
if (signal) {
try {
process.kill(process.pid, signal);
} catch {
process.exitCode = 1;
}
console.error(`feynman terminated because the Pi child exited with ${signal}.`);
process.exitCode = exitCodeFromSignal(signal);
resolvePromise();
return;
}
process.exitCode = code ?? 0;

View File

@@ -1,5 +1,6 @@
import { existsSync, readFileSync } from "node:fs";
import { delimiter, dirname, resolve } from "node:path";
import { delimiter, dirname, isAbsolute, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import {
BROWSER_FALLBACK_PATHS,
@@ -14,12 +15,25 @@ export type PiRuntimeOptions = {
sessionDir: string;
feynmanAgentDir: string;
feynmanVersion?: string;
mode?: "text" | "json" | "rpc";
thinkingLevel?: string;
explicitModelSpec?: string;
oneShotPrompt?: string;
initialPrompt?: string;
};
export function getFeynmanNpmPrefixPath(feynmanAgentDir: string): string {
return resolve(dirname(feynmanAgentDir), "npm-global");
}
export function applyFeynmanPackageManagerEnv(feynmanAgentDir: string): string {
const feynmanNpmPrefixPath = getFeynmanNpmPrefixPath(feynmanAgentDir);
process.env.FEYNMAN_NPM_PREFIX = feynmanNpmPrefixPath;
process.env.NPM_CONFIG_PREFIX = feynmanNpmPrefixPath;
process.env.npm_config_prefix = feynmanNpmPrefixPath;
return feynmanNpmPrefixPath;
}
export function resolvePiPaths(appRoot: string) {
return {
piPackageRoot: resolve(appRoot, "node_modules", "@mariozechner", "pi-coding-agent"),
@@ -35,6 +49,10 @@ export function resolvePiPaths(appRoot: string) {
};
}
export function toNodeImportSpecifier(modulePath: string): string {
return isAbsolute(modulePath) ? pathToFileURL(modulePath).href : modulePath;
}
export function validatePiInstallation(appRoot: string): string[] {
const paths = resolvePiPaths(appRoot);
const missing: string[] = [];
@@ -66,6 +84,9 @@ export function buildPiArgs(options: PiRuntimeOptions): string[] {
args.push("--system-prompt", readFileSync(paths.systemPromptPath, "utf8"));
}
if (options.mode) {
args.push("--mode", options.mode);
}
if (options.explicitModelSpec) {
args.push("--model", options.explicitModelSpec);
}
@@ -83,9 +104,9 @@ export function buildPiArgs(options: PiRuntimeOptions): string[] {
export function buildPiEnv(options: PiRuntimeOptions): NodeJS.ProcessEnv {
const paths = resolvePiPaths(options.appRoot);
const feynmanHome = dirname(options.feynmanAgentDir);
const feynmanNpmPrefixPath = resolve(feynmanHome, "npm-global");
const feynmanNpmPrefixPath = getFeynmanNpmPrefixPath(options.feynmanAgentDir);
const feynmanNpmBinPath = resolve(feynmanNpmPrefixPath, "bin");
const feynmanWebSearchConfigPath = resolve(dirname(options.feynmanAgentDir), "web-search.json");
const currentPath = process.env.PATH ?? "";
const binEntries = [paths.nodeModulesBinPath, resolve(paths.piWorkspaceNodeModulesPath, ".bin"), feynmanNpmBinPath];
@@ -97,6 +118,7 @@ export function buildPiEnv(options: PiRuntimeOptions): NodeJS.ProcessEnv {
FEYNMAN_VERSION: options.feynmanVersion,
FEYNMAN_SESSION_DIR: options.sessionDir,
FEYNMAN_MEMORY_DIR: resolve(dirname(options.feynmanAgentDir), "memory"),
FEYNMAN_WEB_SEARCH_CONFIG: feynmanWebSearchConfigPath,
FEYNMAN_NODE_EXECUTABLE: process.execPath,
FEYNMAN_BIN_PATH: resolve(options.appRoot, "bin", "feynman.js"),
FEYNMAN_NPM_PREFIX: feynmanNpmPrefixPath,

View File

@@ -1,13 +1,15 @@
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { resolve } from "node:path";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { getFeynmanHome } from "../config/paths.js";
export type PiWebSearchProvider = "auto" | "perplexity" | "gemini";
export type PiWebSearchProvider = "auto" | "perplexity" | "exa" | "gemini";
export type PiWebAccessConfig = Record<string, unknown> & {
route?: PiWebSearchProvider;
provider?: PiWebSearchProvider;
searchProvider?: PiWebSearchProvider;
perplexityApiKey?: string;
exaApiKey?: string;
geminiApiKey?: string;
chromeProfile?: string;
};
@@ -17,18 +19,20 @@ export type PiWebAccessStatus = {
searchProvider: PiWebSearchProvider;
requestProvider: PiWebSearchProvider;
perplexityConfigured: boolean;
exaConfigured: boolean;
geminiApiConfigured: boolean;
chromeProfile?: string;
routeLabel: string;
note: string;
};
export function getPiWebSearchConfigPath(home = process.env.HOME ?? homedir()): string {
return resolve(home, ".feynman", "web-search.json");
export function getPiWebSearchConfigPath(home?: string): string {
const feynmanHome = home ? resolve(home, ".feynman") : getFeynmanHome();
return resolve(feynmanHome, "web-search.json");
}
function normalizeProvider(value: unknown): PiWebSearchProvider | undefined {
return value === "auto" || value === "perplexity" || value === "gemini" ? value : undefined;
return value === "auto" || value === "perplexity" || value === "exa" || value === "gemini" ? value : undefined;
}
function normalizeNonEmptyString(value: unknown): string | undefined {
@@ -48,10 +52,29 @@ export function loadPiWebAccessConfig(configPath = getPiWebSearchConfigPath()):
}
}
export function savePiWebAccessConfig(
updates: Partial<Record<keyof PiWebAccessConfig, unknown>>,
configPath = getPiWebSearchConfigPath(),
): void {
const merged: Record<string, unknown> = { ...loadPiWebAccessConfig(configPath) };
for (const [key, value] of Object.entries(updates)) {
if (value === undefined) {
delete merged[key];
} else {
merged[key] = value;
}
}
mkdirSync(dirname(configPath), { recursive: true });
writeFileSync(configPath, JSON.stringify(merged, null, 2) + "\n", "utf8");
}
function formatRouteLabel(provider: PiWebSearchProvider): string {
switch (provider) {
case "perplexity":
return "Perplexity";
case "exa":
return "Exa";
case "gemini":
return "Gemini";
default:
@@ -63,10 +86,12 @@ function formatRouteNote(provider: PiWebSearchProvider): string {
switch (provider) {
case "perplexity":
return "Pi web-access will use Perplexity for search.";
case "exa":
return "Pi web-access will use Exa for search.";
case "gemini":
return "Pi web-access will use Gemini API or Gemini Browser.";
default:
return "Pi web-access will try Perplexity, then Gemini API, then Gemini Browser.";
return "Pi web-access will try Perplexity, then Exa, then Gemini API, then Gemini Browser.";
}
}
@@ -74,9 +99,11 @@ export function getPiWebAccessStatus(
config: PiWebAccessConfig = loadPiWebAccessConfig(),
configPath = getPiWebSearchConfigPath(),
): PiWebAccessStatus {
const searchProvider = normalizeProvider(config.searchProvider) ?? "auto";
const requestProvider = normalizeProvider(config.provider) ?? searchProvider;
const searchProvider =
normalizeProvider(config.searchProvider) ?? normalizeProvider(config.route) ?? normalizeProvider(config.provider) ?? "auto";
const requestProvider = normalizeProvider(config.provider) ?? normalizeProvider(config.route) ?? searchProvider;
const perplexityConfigured = Boolean(normalizeNonEmptyString(config.perplexityApiKey));
const exaConfigured = Boolean(normalizeNonEmptyString(config.exaApiKey));
const geminiApiConfigured = Boolean(normalizeNonEmptyString(config.geminiApiKey));
const chromeProfile = normalizeNonEmptyString(config.chromeProfile);
const effectiveProvider = searchProvider;
@@ -86,6 +113,7 @@ export function getPiWebAccessStatus(
searchProvider,
requestProvider,
perplexityConfigured,
exaConfigured,
geminiApiConfigured,
chromeProfile,
routeLabel: formatRouteLabel(effectiveProvider),
@@ -101,6 +129,7 @@ export function formatPiWebAccessDoctorLines(
` search route: ${status.routeLabel}`,
` request route: ${status.requestProvider}`,
` perplexity api: ${status.perplexityConfigured ? "configured" : "not configured"}`,
` exa api: ${status.exaConfigured ? "configured" : "not configured"}`,
` gemini api: ${status.geminiApiConfigured ? "configured" : "not configured"}`,
` browser profile: ${status.chromeProfile ?? "default Chromium profile"}`,
` config path: ${status.configPath}`,

View File

@@ -1,13 +1,58 @@
import { getPiWebAccessStatus } from "../pi/web-access.js";
import {
getPiWebAccessStatus,
savePiWebAccessConfig,
type PiWebAccessConfig,
type PiWebSearchProvider,
} from "../pi/web-access.js";
import { printInfo } from "../ui/terminal.js";
const SEARCH_PROVIDERS: PiWebSearchProvider[] = ["auto", "perplexity", "exa", "gemini"];
const PROVIDER_API_KEY_FIELDS: Partial<Record<PiWebSearchProvider, keyof PiWebAccessConfig>> = {
perplexity: "perplexityApiKey",
exa: "exaApiKey",
gemini: "geminiApiKey",
};
export function printSearchStatus(): void {
const status = getPiWebAccessStatus();
printInfo("Managed by: pi-web-access");
printInfo(`Search route: ${status.routeLabel}`);
printInfo(`Request route: ${status.requestProvider}`);
printInfo(`Perplexity API configured: ${status.perplexityConfigured ? "yes" : "no"}`);
printInfo(`Exa API configured: ${status.exaConfigured ? "yes" : "no"}`);
printInfo(`Gemini API configured: ${status.geminiApiConfigured ? "yes" : "no"}`);
printInfo(`Browser profile: ${status.chromeProfile ?? "default Chromium profile"}`);
printInfo(`Config path: ${status.configPath}`);
}
export function setSearchProvider(provider: PiWebSearchProvider, apiKey?: string): void {
if (!SEARCH_PROVIDERS.includes(provider)) {
throw new Error(`Usage: feynman search set <${SEARCH_PROVIDERS.join("|")}> [api-key]`);
}
if (apiKey !== undefined && provider === "auto") {
throw new Error("The auto provider does not use an API key. Usage: feynman search set auto");
}
const updates: Partial<Record<keyof PiWebAccessConfig, unknown>> = {
provider,
searchProvider: provider,
route: undefined,
};
const apiKeyField = PROVIDER_API_KEY_FIELDS[provider];
if (apiKeyField && apiKey !== undefined) {
updates[apiKeyField] = apiKey;
}
savePiWebAccessConfig(updates);
const status = getPiWebAccessStatus();
console.log(`Web search provider set to ${status.routeLabel}.`);
console.log(`Config path: ${status.configPath}`);
}
export function clearSearchConfig(): void {
savePiWebAccessConfig({ provider: undefined, searchProvider: undefined, route: undefined });
const status = getPiWebAccessStatus();
console.log(`Web search provider reset to ${status.routeLabel}.`);
console.log(`Config path: ${status.configPath}`);
}

View File

@@ -10,6 +10,7 @@ import { printInfo, printPanel, printSection } from "../ui/terminal.js";
import { getCurrentModelSpec } from "../model/commands.js";
import { buildModelStatusSnapshotFromRecords, getAvailableModelRecords, getSupportedModelRecords } from "../model/catalog.js";
import { createModelRegistry, getModelsJsonPath } from "../model/registry.js";
import { getConfiguredServiceTier } from "../model/service-tier.js";
function findProvidersMissingApiKey(modelsJsonPath: string): string[] {
try {
@@ -105,6 +106,7 @@ export function runStatus(options: DoctorOptions): void {
printInfo(`Recommended model: ${snapshot.recommendedModel ?? "not available"}`);
printInfo(`alphaXiv: ${snapshot.alphaLoggedIn ? snapshot.alphaUser ?? "configured" : "not configured"}`);
printInfo(`Web access: pi-web-access (${snapshot.webRouteLabel})`);
printInfo(`Service tier: ${getConfiguredServiceTier(options.settingsPath) ?? "not set"}`);
printInfo(`Preview: ${snapshot.previewConfigured ? "configured" : "not configured"}`);
printSection("Paths");
@@ -165,6 +167,7 @@ export function runDoctor(options: DoctorOptions): void {
console.log(`default model valid: ${modelStatus.modelValid ? "yes" : "no"}`);
console.log(`authenticated providers: ${modelStatus.authenticatedProviderCount}`);
console.log(`authenticated models: ${modelStatus.authenticatedModelCount}`);
console.log(`service tier: ${getConfiguredServiceTier(options.settingsPath) ?? "not set"}`);
console.log(`recommended model: ${modelStatus.recommendedModel ?? "not available"}`);
if (modelStatus.recommendedModelReason) {
console.log(` why: ${modelStatus.recommendedModelReason}`);

View File

@@ -0,0 +1,110 @@
import test from "node:test";
import assert from "node:assert/strict";
import { buildModelStatusSnapshotFromRecords } from "../src/model/catalog.js";
test("buildModelStatusSnapshotFromRecords returns empty guidance when model is set and valid", () => {
const snapshot = buildModelStatusSnapshotFromRecords(
[{ provider: "anthropic", id: "claude-opus-4-6" }],
[{ provider: "anthropic", id: "claude-opus-4-6" }],
"anthropic/claude-opus-4-6",
);
assert.equal(snapshot.currentValid, true);
assert.equal(snapshot.current, "anthropic/claude-opus-4-6");
assert.equal(snapshot.guidance.length, 0);
});
test("buildModelStatusSnapshotFromRecords emits guidance when no models are available", () => {
const snapshot = buildModelStatusSnapshotFromRecords([], [], undefined);
assert.equal(snapshot.currentValid, false);
assert.equal(snapshot.current, undefined);
assert.equal(snapshot.recommended, undefined);
assert.ok(snapshot.guidance.some((line) => line.includes("No authenticated Pi models")));
});
test("buildModelStatusSnapshotFromRecords emits guidance when no default model is set", () => {
const snapshot = buildModelStatusSnapshotFromRecords(
[{ provider: "openai", id: "gpt-5.4" }],
[{ provider: "openai", id: "gpt-5.4" }],
undefined,
);
assert.equal(snapshot.currentValid, false);
assert.equal(snapshot.current, undefined);
assert.ok(snapshot.guidance.some((line) => line.includes("No default research model")));
});
test("buildModelStatusSnapshotFromRecords marks provider as configured only when it has available models", () => {
const snapshot = buildModelStatusSnapshotFromRecords(
[
{ provider: "anthropic", id: "claude-opus-4-6" },
{ provider: "openai", id: "gpt-5.4" },
],
[{ provider: "openai", id: "gpt-5.4" }],
"openai/gpt-5.4",
);
const anthropicProvider = snapshot.providers.find((provider) => provider.id === "anthropic");
const openaiProvider = snapshot.providers.find((provider) => provider.id === "openai");
assert.ok(anthropicProvider);
assert.equal(anthropicProvider!.configured, false);
assert.equal(anthropicProvider!.supportedModels, 1);
assert.equal(anthropicProvider!.availableModels, 0);
assert.ok(openaiProvider);
assert.equal(openaiProvider!.configured, true);
assert.equal(openaiProvider!.supportedModels, 1);
assert.equal(openaiProvider!.availableModels, 1);
});
test("buildModelStatusSnapshotFromRecords marks provider as current when selected model belongs to it", () => {
const snapshot = buildModelStatusSnapshotFromRecords(
[
{ provider: "anthropic", id: "claude-opus-4-6" },
{ provider: "openai", id: "gpt-5.4" },
],
[
{ provider: "anthropic", id: "claude-opus-4-6" },
{ provider: "openai", id: "gpt-5.4" },
],
"anthropic/claude-opus-4-6",
);
const anthropicProvider = snapshot.providers.find((provider) => provider.id === "anthropic");
const openaiProvider = snapshot.providers.find((provider) => provider.id === "openai");
assert.equal(anthropicProvider!.current, true);
assert.equal(openaiProvider!.current, false);
});
test("buildModelStatusSnapshotFromRecords returns available models sorted by research preference", () => {
const snapshot = buildModelStatusSnapshotFromRecords(
[
{ provider: "openai", id: "gpt-5.4" },
{ provider: "anthropic", id: "claude-opus-4-6" },
],
[
{ provider: "openai", id: "gpt-5.4" },
{ provider: "anthropic", id: "claude-opus-4-6" },
],
undefined,
);
assert.equal(snapshot.availableModels[0], "anthropic/claude-opus-4-6");
assert.equal(snapshot.availableModels[1], "openai/gpt-5.4");
assert.equal(snapshot.recommended, "anthropic/claude-opus-4-6");
});
test("buildModelStatusSnapshotFromRecords sets currentValid false when current model is not in available list", () => {
const snapshot = buildModelStatusSnapshotFromRecords(
[{ provider: "anthropic", id: "claude-opus-4-6" }],
[],
"anthropic/claude-opus-4-6",
);
assert.equal(snapshot.currentValid, false);
assert.equal(snapshot.current, "anthropic/claude-opus-4-6");
});

View File

@@ -0,0 +1,92 @@
import test from "node:test";
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import {
ensureFeynmanHome,
getBootstrapStatePath,
getDefaultSessionDir,
getFeynmanAgentDir,
getFeynmanHome,
getFeynmanMemoryDir,
getFeynmanStateDir,
} from "../src/config/paths.js";
test("getFeynmanHome uses FEYNMAN_HOME env var when set", () => {
const previous = process.env.FEYNMAN_HOME;
try {
process.env.FEYNMAN_HOME = "/custom/home";
assert.equal(getFeynmanHome(), resolve("/custom/home", ".feynman"));
} finally {
if (previous === undefined) {
delete process.env.FEYNMAN_HOME;
} else {
process.env.FEYNMAN_HOME = previous;
}
}
});
test("getFeynmanHome falls back to homedir when FEYNMAN_HOME is unset", () => {
const previous = process.env.FEYNMAN_HOME;
try {
delete process.env.FEYNMAN_HOME;
const home = getFeynmanHome();
assert.ok(home.endsWith(".feynman"), `expected path ending in .feynman, got: ${home}`);
assert.ok(!home.includes("undefined"), `expected no 'undefined' in path, got: ${home}`);
} finally {
if (previous === undefined) {
delete process.env.FEYNMAN_HOME;
} else {
process.env.FEYNMAN_HOME = previous;
}
}
});
test("getFeynmanAgentDir resolves to <home>/agent", () => {
assert.equal(getFeynmanAgentDir("/some/home"), resolve("/some/home", "agent"));
});
test("getFeynmanMemoryDir resolves to <home>/memory", () => {
assert.equal(getFeynmanMemoryDir("/some/home"), resolve("/some/home", "memory"));
});
test("getFeynmanStateDir resolves to <home>/.state", () => {
assert.equal(getFeynmanStateDir("/some/home"), resolve("/some/home", ".state"));
});
test("getDefaultSessionDir resolves to <home>/sessions", () => {
assert.equal(getDefaultSessionDir("/some/home"), resolve("/some/home", "sessions"));
});
test("getBootstrapStatePath resolves to <home>/.state/bootstrap.json", () => {
assert.equal(getBootstrapStatePath("/some/home"), resolve("/some/home", ".state", "bootstrap.json"));
});
test("ensureFeynmanHome creates all required subdirectories", () => {
const root = mkdtempSync(join(tmpdir(), "feynman-paths-"));
try {
const home = join(root, "home");
ensureFeynmanHome(home);
assert.ok(existsSync(home), "home dir should exist");
assert.ok(existsSync(join(home, "agent")), "agent dir should exist");
assert.ok(existsSync(join(home, "memory")), "memory dir should exist");
assert.ok(existsSync(join(home, ".state")), ".state dir should exist");
assert.ok(existsSync(join(home, "sessions")), "sessions dir should exist");
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("ensureFeynmanHome is idempotent when dirs already exist", () => {
const root = mkdtempSync(join(tmpdir(), "feynman-paths-"));
try {
const home = join(root, "home");
ensureFeynmanHome(home);
assert.doesNotThrow(() => ensureFeynmanHome(home));
} finally {
rmSync(root, { recursive: true, force: true });
}
});

View File

@@ -0,0 +1,32 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readdirSync, readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const bannedPatterns = [/ValiChord/i, /Harmony Record/i, /harmony_record_/i];
function collectMarkdownFiles(root: string): string[] {
const files: string[] = [];
for (const entry of readdirSync(root, { withFileTypes: true })) {
const fullPath = join(root, entry.name);
if (entry.isDirectory()) {
files.push(...collectMarkdownFiles(fullPath));
continue;
}
if (entry.isFile() && fullPath.endsWith(".md")) {
files.push(fullPath);
}
}
return files;
}
test("bundled prompts and skills do not contain blocked promotional product content", () => {
for (const filePath of [...collectMarkdownFiles(join(repoRoot, "prompts")), ...collectMarkdownFiles(join(repoRoot, "skills"))]) {
const content = readFileSync(filePath, "utf8");
for (const pattern of bannedPatterns) {
assert.doesNotMatch(content, pattern, `${filePath} contains blocked promotional pattern ${pattern}`);
}
}
});

View File

@@ -57,6 +57,16 @@ test("buildModelStatusSnapshotFromRecords flags an invalid current model and sug
assert.ok(snapshot.guidance.some((line) => line.includes("Configured default model is unavailable")));
});
test("chooseRecommendedModel prefers MiniMax M2.7 over highspeed when that is the authenticated provider", () => {
const authPath = createAuthPath({
minimax: { type: "api_key", key: "minimax-test-key" },
});
const recommendation = chooseRecommendedModel(authPath);
assert.equal(recommendation?.spec, "minimax/MiniMax-M2.7");
});
test("resolveInitialPrompt maps top-level research commands to Pi slash workflows", () => {
const workflows = new Set(["lit", "watch", "jobs", "deepresearch"]);
assert.equal(resolveInitialPrompt("lit", ["tool-using", "agents"], undefined, workflows), "/lit tool-using agents");
@@ -65,4 +75,3 @@ test("resolveInitialPrompt maps top-level research commands to Pi slash workflow
assert.equal(resolveInitialPrompt("chat", ["hello"], undefined, workflows), "hello");
assert.equal(resolveInitialPrompt("unknown", ["topic"], undefined, workflows), "unknown topic");
});

View File

@@ -0,0 +1,42 @@
import test from "node:test";
import assert from "node:assert/strict";
import { patchPiExtensionLoaderSource } from "../scripts/lib/pi-extension-loader-patch.mjs";
test("patchPiExtensionLoaderSource rewrites Windows extension imports to file URLs", () => {
const input = [
'import * as path from "node:path";',
'import { fileURLToPath } from "node:url";',
"async function loadExtensionModule(extensionPath) {",
" const jiti = createJiti(import.meta.url);",
' const module = await jiti.import(extensionPath, { default: true });',
" return module;",
"}",
"",
].join("\n");
const patched = patchPiExtensionLoaderSource(input);
assert.match(patched, /pathToFileURL/);
assert.match(patched, /process\.platform === "win32"/);
assert.match(patched, /path\.isAbsolute\(extensionPath\)/);
assert.match(patched, /jiti\.import\(extensionSpecifier, \{ default: true \}\)/);
});
test("patchPiExtensionLoaderSource is idempotent", () => {
const input = [
'import * as path from "node:path";',
'import { fileURLToPath } from "node:url";',
"async function loadExtensionModule(extensionPath) {",
" const jiti = createJiti(import.meta.url);",
' const module = await jiti.import(extensionPath, { default: true });',
" return module;",
"}",
"",
].join("\n");
const once = patchPiExtensionLoaderSource(input);
const twice = patchPiExtensionLoaderSource(once);
assert.equal(twice, once);
});

View File

@@ -0,0 +1,42 @@
import test from "node:test";
import assert from "node:assert/strict";
import { patchPiGoogleLegacySchemaSource } from "../scripts/lib/pi-google-legacy-schema-patch.mjs";
test("patchPiGoogleLegacySchemaSource rewrites legacy parameters conversion to normalize const", () => {
const input = [
"export function convertTools(tools, useParameters = false) {",
" if (tools.length === 0) return undefined;",
" return [",
" {",
" functionDeclarations: tools.map((tool) => ({",
" name: tool.name,",
" description: tool.description,",
' ...(useParameters ? { parameters: tool.parameters } : { parametersJsonSchema: tool.parameters }),',
" })),",
" },",
" ];",
"}",
"",
].join("\n");
const patched = patchPiGoogleLegacySchemaSource(input);
assert.match(patched, /function normalizeLegacyToolSchema\(schema\)/);
assert.match(patched, /normalized\.enum = \[value\]/);
assert.match(patched, /parameters: normalizeLegacyToolSchema\(tool\.parameters\)/);
});
test("patchPiGoogleLegacySchemaSource is idempotent", () => {
const input = [
"export function convertTools(tools, useParameters = false) {",
' ...(useParameters ? { parameters: tool.parameters } : { parametersJsonSchema: tool.parameters }),',
"}",
"",
].join("\n");
const once = patchPiGoogleLegacySchemaSource(input);
const twice = patchPiGoogleLegacySchemaSource(once);
assert.equal(twice, once);
});

9
tests/pi-launch.test.ts Normal file
View File

@@ -0,0 +1,9 @@
import test from "node:test";
import assert from "node:assert/strict";
import { exitCodeFromSignal } from "../src/pi/launch.js";
test("exitCodeFromSignal maps POSIX signals to conventional shell exit codes", () => {
assert.equal(exitCodeFromSignal("SIGTERM"), 143);
assert.equal(exitCodeFromSignal("SIGSEGV"), 139);
});

View File

@@ -1,7 +1,8 @@
import test from "node:test";
import assert from "node:assert/strict";
import { pathToFileURL } from "node:url";
import { buildPiArgs, buildPiEnv, resolvePiPaths } from "../src/pi/runtime.js";
import { applyFeynmanPackageManagerEnv, buildPiArgs, buildPiEnv, resolvePiPaths, toNodeImportSpecifier } from "../src/pi/runtime.js";
test("buildPiArgs includes configured runtime paths and prompt", () => {
const args = buildPiArgs({
@@ -9,6 +10,7 @@ test("buildPiArgs includes configured runtime paths and prompt", () => {
workingDir: "/workspace",
sessionDir: "/sessions",
feynmanAgentDir: "/home/.feynman/agent",
mode: "rpc",
initialPrompt: "hello",
explicitModelSpec: "openai:gpt-5.4",
thinkingLevel: "medium",
@@ -21,6 +23,8 @@ test("buildPiArgs includes configured runtime paths and prompt", () => {
"/repo/feynman/extensions/research-tools.ts",
"--prompt-template",
"/repo/feynman/prompts",
"--mode",
"rpc",
"--model",
"openai:gpt-5.4",
"--thinking",
@@ -70,8 +74,47 @@ test("buildPiEnv wires Feynman paths into the Pi environment", () => {
}
});
test("applyFeynmanPackageManagerEnv pins npm globals to the Feynman prefix", () => {
const previousFeynmanPrefix = process.env.FEYNMAN_NPM_PREFIX;
const previousUppercasePrefix = process.env.NPM_CONFIG_PREFIX;
const previousLowercasePrefix = process.env.npm_config_prefix;
try {
const prefix = applyFeynmanPackageManagerEnv("/home/.feynman/agent");
assert.equal(prefix, "/home/.feynman/npm-global");
assert.equal(process.env.FEYNMAN_NPM_PREFIX, "/home/.feynman/npm-global");
assert.equal(process.env.NPM_CONFIG_PREFIX, "/home/.feynman/npm-global");
assert.equal(process.env.npm_config_prefix, "/home/.feynman/npm-global");
} finally {
if (previousFeynmanPrefix === undefined) {
delete process.env.FEYNMAN_NPM_PREFIX;
} else {
process.env.FEYNMAN_NPM_PREFIX = previousFeynmanPrefix;
}
if (previousUppercasePrefix === undefined) {
delete process.env.NPM_CONFIG_PREFIX;
} else {
process.env.NPM_CONFIG_PREFIX = previousUppercasePrefix;
}
if (previousLowercasePrefix === undefined) {
delete process.env.npm_config_prefix;
} else {
process.env.npm_config_prefix = previousLowercasePrefix;
}
}
});
test("resolvePiPaths includes the Promise.withResolvers polyfill path", () => {
const paths = resolvePiPaths("/repo/feynman");
assert.equal(paths.promisePolyfillPath, "/repo/feynman/dist/system/promise-polyfill.js");
});
test("toNodeImportSpecifier converts absolute preload paths to file URLs", () => {
assert.equal(
toNodeImportSpecifier("/repo/feynman/dist/system/promise-polyfill.js"),
pathToFileURL("/repo/feynman/dist/system/promise-polyfill.js").href,
);
assert.equal(toNodeImportSpecifier("tsx"), "tsx");
});

View File

@@ -0,0 +1,48 @@
import test from "node:test";
import assert from "node:assert/strict";
import { patchPiWebAccessSource } from "../scripts/lib/pi-web-access-patch.mjs";
test("patchPiWebAccessSource rewrites legacy Pi web-search config paths", () => {
const input = [
'import { join } from "node:path";',
'import { homedir } from "node:os";',
'const CONFIG_PATH = join(homedir(), ".pi", "web-search.json");',
"",
].join("\n");
const patched = patchPiWebAccessSource("perplexity.ts", input);
assert.match(patched, /FEYNMAN_WEB_SEARCH_CONFIG/);
assert.match(patched, /PI_WEB_SEARCH_CONFIG/);
});
test("patchPiWebAccessSource updates index.ts directory handling", () => {
const input = [
'import { existsSync, mkdirSync } from "node:fs";',
'import { join } from "node:path";',
'import { homedir } from "node:os";',
'const WEB_SEARCH_CONFIG_PATH = join(homedir(), ".pi", "web-search.json");',
'const dir = join(homedir(), ".pi");',
"",
].join("\n");
const patched = patchPiWebAccessSource("index.ts", input);
assert.match(patched, /import \{ dirname, join \} from "node:path";/);
assert.match(patched, /const dir = dirname\(WEB_SEARCH_CONFIG_PATH\);/);
});
test("patchPiWebAccessSource is idempotent", () => {
const input = [
'import { join } from "node:path";',
'import { homedir } from "node:os";',
'const CONFIG_PATH = join(homedir(), ".pi", "web-search.json");',
"",
].join("\n");
const once = patchPiWebAccessSource("perplexity.ts", input);
const twice = patchPiWebAccessSource("perplexity.ts", once);
assert.equal(twice, once);
});

View File

@@ -9,6 +9,7 @@ import {
getPiWebAccessStatus,
getPiWebSearchConfigPath,
loadPiWebAccessConfig,
savePiWebAccessConfig,
} from "../src/pi/web-access.js";
test("loadPiWebAccessConfig returns empty config when Pi web config is missing", () => {
@@ -18,7 +19,56 @@ test("loadPiWebAccessConfig returns empty config when Pi web config is missing",
assert.deepEqual(loadPiWebAccessConfig(configPath), {});
});
test("getPiWebSearchConfigPath respects FEYNMAN_HOME semantics", () => {
assert.equal(getPiWebSearchConfigPath("/tmp/custom-home"), "/tmp/custom-home/.feynman/web-search.json");
});
test("savePiWebAccessConfig merges updates and deletes undefined values", () => {
const root = mkdtempSync(join(tmpdir(), "feynman-pi-web-"));
const configPath = getPiWebSearchConfigPath(root);
savePiWebAccessConfig({
provider: "perplexity",
searchProvider: "perplexity",
perplexityApiKey: "pplx_...",
}, configPath);
savePiWebAccessConfig({
provider: undefined,
searchProvider: undefined,
route: undefined,
}, configPath);
assert.deepEqual(loadPiWebAccessConfig(configPath), {
perplexityApiKey: "pplx_...",
});
});
test("getPiWebAccessStatus reads Pi web-access config directly", () => {
const root = mkdtempSync(join(tmpdir(), "feynman-pi-web-"));
const configPath = getPiWebSearchConfigPath(root);
mkdirSync(join(root, ".feynman"), { recursive: true });
writeFileSync(
configPath,
JSON.stringify({
provider: "exa",
searchProvider: "exa",
exaApiKey: "exa_...",
chromeProfile: "Profile 2",
geminiApiKey: "AIza...",
}),
"utf8",
);
const status = getPiWebAccessStatus(loadPiWebAccessConfig(configPath), configPath);
assert.equal(status.routeLabel, "Exa");
assert.equal(status.requestProvider, "exa");
assert.equal(status.exaConfigured, true);
assert.equal(status.geminiApiConfigured, true);
assert.equal(status.perplexityConfigured, false);
assert.equal(status.chromeProfile, "Profile 2");
});
test("getPiWebAccessStatus reads Gemini routes directly", () => {
const root = mkdtempSync(join(tmpdir(), "feynman-pi-web-"));
const configPath = getPiWebSearchConfigPath(root);
mkdirSync(join(root, ".feynman"), { recursive: true });
@@ -36,11 +86,23 @@ test("getPiWebAccessStatus reads Pi web-access config directly", () => {
const status = getPiWebAccessStatus(loadPiWebAccessConfig(configPath), configPath);
assert.equal(status.routeLabel, "Gemini");
assert.equal(status.requestProvider, "gemini");
assert.equal(status.exaConfigured, false);
assert.equal(status.geminiApiConfigured, true);
assert.equal(status.perplexityConfigured, false);
assert.equal(status.chromeProfile, "Profile 2");
});
test("getPiWebAccessStatus supports the legacy route key", () => {
const status = getPiWebAccessStatus({
route: "perplexity",
perplexityApiKey: "pplx_...",
});
assert.equal(status.routeLabel, "Perplexity");
assert.equal(status.requestProvider, "perplexity");
assert.equal(status.perplexityConfigured, true);
});
test("formatPiWebAccessDoctorLines reports Pi-managed web access", () => {
const lines = formatPiWebAccessDoctorLines(
getPiWebAccessStatus({

View File

@@ -0,0 +1,41 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
getConfiguredServiceTier,
normalizeServiceTier,
resolveProviderServiceTier,
setConfiguredServiceTier,
} from "../src/model/service-tier.js";
test("normalizeServiceTier accepts supported values only", () => {
assert.equal(normalizeServiceTier("priority"), "priority");
assert.equal(normalizeServiceTier("standard_only"), "standard_only");
assert.equal(normalizeServiceTier("FAST"), undefined);
assert.equal(normalizeServiceTier(undefined), undefined);
});
test("setConfiguredServiceTier persists and clears settings.json values", () => {
const dir = mkdtempSync(join(tmpdir(), "feynman-service-tier-"));
const settingsPath = join(dir, "settings.json");
setConfiguredServiceTier(settingsPath, "priority");
assert.equal(getConfiguredServiceTier(settingsPath), "priority");
const persisted = JSON.parse(readFileSync(settingsPath, "utf8")) as { serviceTier?: string };
assert.equal(persisted.serviceTier, "priority");
setConfiguredServiceTier(settingsPath, undefined);
assert.equal(getConfiguredServiceTier(settingsPath), undefined);
});
test("resolveProviderServiceTier filters unsupported provider+tier pairs", () => {
assert.equal(resolveProviderServiceTier("openai", "priority"), "priority");
assert.equal(resolveProviderServiceTier("openai-codex", "flex"), "flex");
assert.equal(resolveProviderServiceTier("anthropic", "standard_only"), "standard_only");
assert.equal(resolveProviderServiceTier("anthropic", "priority"), undefined);
assert.equal(resolveProviderServiceTier("google", "priority"), undefined);
});

28
tests/skill-paths.test.ts Normal file
View File

@@ -0,0 +1,28 @@
import test from "node:test";
import assert from "node:assert/strict";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const skillsRoot = join(repoRoot, "skills");
const markdownPathPattern = /`((?:\.\.?\/)(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+\.md)`/g;
const simulatedInstallRoot = join(repoRoot, "__skill-install-root__");
test("all local markdown references in bundled skills resolve in the installed skill layout", () => {
for (const entry of readdirSync(skillsRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const skillPath = join(skillsRoot, entry.name, "SKILL.md");
if (!existsSync(skillPath)) continue;
const content = readFileSync(skillPath, "utf8");
for (const match of content.matchAll(markdownPathPattern)) {
const reference = match[1];
const installedSkillDir = join(simulatedInstallRoot, entry.name);
const installedTarget = resolve(installedSkillDir, reference);
const repoTarget = installedTarget.replace(simulatedInstallRoot, repoRoot);
assert.ok(existsSync(repoTarget), `${skillPath} references missing installed markdown file ${reference}`);
}
}
});

View File

@@ -7,9 +7,6 @@
"": {
"name": "website",
"version": "0.0.1",
"engines": {
"node": ">=20.19.0"
},
"dependencies": {
"@astrojs/react": "^4.4.2",
"@fontsource-variable/ibm-plex-sans": "^5.2.8",
@@ -29,6 +26,7 @@
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@astrojs/check": "^0.9.8",
"@eslint/js": "^9.39.4",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
@@ -39,6 +37,68 @@
"prettier-plugin-tailwindcss": "^0.7.2",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.1"
},
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@astrojs/check": {
"version": "0.9.8",
"resolved": "https://registry.npmjs.org/@astrojs/check/-/check-0.9.8.tgz",
"integrity": "sha512-LDng8446QLS5ToKjRHd3bgUdirvemVVExV7nRyJfW2wV36xuv7vDxwy5NWN9zqeSEDgg0Tv84sP+T3yEq+Zlkw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@astrojs/language-server": "^2.16.5",
"chokidar": "^4.0.3",
"kleur": "^4.1.5",
"yargs": "^17.7.2"
},
"bin": {
"astro-check": "bin/astro-check.js"
},
"peerDependencies": {
"typescript": "^5.0.0"
}
},
"node_modules/@astrojs/check/node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@astrojs/check/node_modules/kleur": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
"integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@astrojs/check/node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@astrojs/compiler": {
@@ -53,6 +113,48 @@
"integrity": "sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q==",
"license": "MIT"
},
"node_modules/@astrojs/language-server": {
"version": "2.16.6",
"resolved": "https://registry.npmjs.org/@astrojs/language-server/-/language-server-2.16.6.tgz",
"integrity": "sha512-N990lu+HSFiG57owR0XBkr02BYMgiLCshLf+4QG4v6jjSWkBeQGnzqi+E1L08xFPPJ7eEeXnxPXGLaVv5pa4Ug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@astrojs/compiler": "^2.13.1",
"@astrojs/yaml2ts": "^0.2.3",
"@jridgewell/sourcemap-codec": "^1.5.5",
"@volar/kit": "~2.4.28",
"@volar/language-core": "~2.4.28",
"@volar/language-server": "~2.4.28",
"@volar/language-service": "~2.4.28",
"muggle-string": "^0.4.1",
"tinyglobby": "^0.2.15",
"volar-service-css": "0.0.70",
"volar-service-emmet": "0.0.70",
"volar-service-html": "0.0.70",
"volar-service-prettier": "0.0.70",
"volar-service-typescript": "0.0.70",
"volar-service-typescript-twoslash-queries": "0.0.70",
"volar-service-yaml": "0.0.70",
"vscode-html-languageservice": "^5.6.2",
"vscode-uri": "^3.1.0"
},
"bin": {
"astro-ls": "bin/nodeServer.js"
},
"peerDependencies": {
"prettier": "^3.0.0",
"prettier-plugin-astro": ">=0.11.0"
},
"peerDependenciesMeta": {
"prettier": {
"optional": true
},
"prettier-plugin-astro": {
"optional": true
}
}
},
"node_modules/@astrojs/markdown-remark": {
"version": "6.3.11",
"resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-6.3.11.tgz",
@@ -132,6 +234,16 @@
"node": "18.20.8 || ^20.3.0 || >=22.0.0"
}
},
"node_modules/@astrojs/yaml2ts": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@astrojs/yaml2ts/-/yaml2ts-0.2.3.tgz",
"integrity": "sha512-PJzRmgQzUxI2uwpdX2lXSHtP4G8ocp24/t+bZyf5Fy0SZLSF9f9KXZoMlFM/XCGue+B0nH/2IZ7FpBYQATBsCg==",
"dev": true,
"license": "MIT",
"dependencies": {
"yaml": "^2.8.2"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -738,6 +850,68 @@
"@noble/ciphers": "^1.0.0"
}
},
"node_modules/@emmetio/abbreviation": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/@emmetio/abbreviation/-/abbreviation-2.3.3.tgz",
"integrity": "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@emmetio/scanner": "^1.0.4"
}
},
"node_modules/@emmetio/css-abbreviation": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/@emmetio/css-abbreviation/-/css-abbreviation-2.1.8.tgz",
"integrity": "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@emmetio/scanner": "^1.0.4"
}
},
"node_modules/@emmetio/css-parser": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@emmetio/css-parser/-/css-parser-0.4.1.tgz",
"integrity": "sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@emmetio/stream-reader": "^2.2.0",
"@emmetio/stream-reader-utils": "^0.1.0"
}
},
"node_modules/@emmetio/html-matcher": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@emmetio/html-matcher/-/html-matcher-1.3.0.tgz",
"integrity": "sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"@emmetio/scanner": "^1.0.0"
}
},
"node_modules/@emmetio/scanner": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@emmetio/scanner/-/scanner-1.0.4.tgz",
"integrity": "sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==",
"dev": true,
"license": "MIT"
},
"node_modules/@emmetio/stream-reader": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@emmetio/stream-reader/-/stream-reader-2.2.0.tgz",
"integrity": "sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw==",
"dev": true,
"license": "MIT"
},
"node_modules/@emmetio/stream-reader-utils": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@emmetio/stream-reader-utils/-/stream-reader-utils-0.1.0.tgz",
"integrity": "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A==",
"dev": true,
"license": "MIT"
},
"node_modules/@emnapi/runtime": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
@@ -1369,9 +1543,9 @@
}
},
"node_modules/@hono/node-server": {
"version": "1.19.11",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
"version": "1.19.13",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz",
"integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
@@ -4487,27 +4661,6 @@
"path-browserify": "^1.0.1"
}
},
"node_modules/@ts-morph/common/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@ts-morph/common/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@ts-morph/common/node_modules/minimatch": {
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
@@ -4523,6 +4676,22 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@ts-morph/common/node_modules/minimatch/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/@ts-morph/common/node_modules/minimatch/node_modules/brace-expansion": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -4843,29 +5012,6 @@
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
@@ -4882,6 +5028,24 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch/node_modules/brace-expansion": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@@ -4976,6 +5140,104 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@volar/kit": {
"version": "2.4.28",
"resolved": "https://registry.npmjs.org/@volar/kit/-/kit-2.4.28.tgz",
"integrity": "sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/language-service": "2.4.28",
"@volar/typescript": "2.4.28",
"typesafe-path": "^0.2.2",
"vscode-languageserver-textdocument": "^1.0.11",
"vscode-uri": "^3.0.8"
},
"peerDependencies": {
"typescript": "*"
}
},
"node_modules/@volar/language-core": {
"version": "2.4.28",
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz",
"integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/source-map": "2.4.28"
}
},
"node_modules/@volar/language-server": {
"version": "2.4.28",
"resolved": "https://registry.npmjs.org/@volar/language-server/-/language-server-2.4.28.tgz",
"integrity": "sha512-NqcLnE5gERKuS4PUFwlhMxf6vqYo7hXtbMFbViXcbVkbZ905AIVWhnSo0ZNBC2V127H1/2zP7RvVOVnyITFfBw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/language-core": "2.4.28",
"@volar/language-service": "2.4.28",
"@volar/typescript": "2.4.28",
"path-browserify": "^1.0.1",
"request-light": "^0.7.0",
"vscode-languageserver": "^9.0.1",
"vscode-languageserver-protocol": "^3.17.5",
"vscode-languageserver-textdocument": "^1.0.11",
"vscode-uri": "^3.0.8"
}
},
"node_modules/@volar/language-service": {
"version": "2.4.28",
"resolved": "https://registry.npmjs.org/@volar/language-service/-/language-service-2.4.28.tgz",
"integrity": "sha512-Rh/wYCZJrI5vCwMk9xyw/Z+MsWxlJY1rmMZPsxUoJKfzIRjS/NF1NmnuEcrMbEVGja00aVpCsInJfixQTMdvLw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/language-core": "2.4.28",
"vscode-languageserver-protocol": "^3.17.5",
"vscode-languageserver-textdocument": "^1.0.11",
"vscode-uri": "^3.0.8"
}
},
"node_modules/@volar/source-map": {
"version": "2.4.28",
"resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz",
"integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@volar/typescript": {
"version": "2.4.28",
"resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz",
"integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/language-core": "2.4.28",
"path-browserify": "^1.0.1",
"vscode-uri": "^3.0.8"
}
},
"node_modules/@vscode/emmet-helper": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@vscode/emmet-helper/-/emmet-helper-2.11.0.tgz",
"integrity": "sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw==",
"dev": true,
"license": "MIT",
"dependencies": {
"emmet": "^2.4.3",
"jsonc-parser": "^2.3.0",
"vscode-languageserver-textdocument": "^1.0.1",
"vscode-languageserver-types": "^3.15.1",
"vscode-uri": "^3.0.8"
}
},
"node_modules/@vscode/l10n": {
"version": "0.0.18",
"resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz",
"integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -5419,9 +5681,9 @@
}
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5859,7 +6121,6 @@
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true,
"license": "MIT"
},
"node_modules/content-disposition": {
@@ -6186,9 +6447,9 @@
}
},
"node_modules/defu": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
"version": "6.1.7",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"license": "MIT"
},
"node_modules/depd": {
@@ -6407,6 +6668,23 @@
"integrity": "sha512-vFU34OcrvMcH66T+dYC3G4nURmgfDVewMIu6Q2urXpumAPSMmzvcn04KVVV8Opikq8Vs5nUbO/8laNhNRqSzYw==",
"license": "ISC"
},
"node_modules/emmet": {
"version": "2.4.11",
"resolved": "https://registry.npmjs.org/emmet/-/emmet-2.4.11.tgz",
"integrity": "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==",
"dev": true,
"license": "MIT",
"workspaces": [
"./packages/scanner",
"./packages/abbreviation",
"./packages/css-abbreviation",
"./"
],
"dependencies": {
"@emmetio/abbreviation": "^2.3.3",
"@emmetio/css-abbreviation": "^2.1.8"
}
},
"node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
@@ -7677,9 +7955,9 @@
}
},
"node_modules/hono": {
"version": "4.12.9",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz",
"integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==",
"version": "4.12.12",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz",
"integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -8131,6 +8409,13 @@
"node": ">=6"
}
},
"node_modules/jsonc-parser": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.3.1.tgz",
"integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==",
"dev": true,
"license": "MIT"
},
"node_modules/jsonfile": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
@@ -9558,6 +9843,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/muggle-string": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
"integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
"dev": true,
"license": "MIT"
},
"node_modules/mute-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz",
@@ -10813,6 +11105,13 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/request-light": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/request-light/-/request-light-0.7.0.tgz",
"integrity": "sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q==",
"dev": true,
"license": "MIT"
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -10994,9 +11293,9 @@
}
},
"node_modules/router/node_modules/path-to-regexp": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -11856,6 +12155,13 @@
"node": ">= 0.6"
}
},
"node_modules/typesafe-path": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/typesafe-path/-/typesafe-path-0.2.2.tgz",
"integrity": "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA==",
"dev": true,
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -11869,6 +12175,29 @@
"node": ">=14.17"
}
},
"node_modules/typescript-auto-import-cache": {
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/typescript-auto-import-cache/-/typescript-auto-import-cache-0.3.6.tgz",
"integrity": "sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.3.8"
}
},
"node_modules/typescript-auto-import-cache/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/typescript-eslint": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.2.tgz",
@@ -12367,9 +12696,9 @@
}
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
@@ -12916,6 +13245,274 @@
}
}
},
"node_modules/volar-service-css": {
"version": "0.0.70",
"resolved": "https://registry.npmjs.org/volar-service-css/-/volar-service-css-0.0.70.tgz",
"integrity": "sha512-K1qyOvBpE3rzdAv3e4/6Rv5yizrYPy5R/ne3IWCAzLBuMO4qBMV3kSqWzj6KUVe6S0AnN6wxF7cRkiaKfYMYJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"vscode-css-languageservice": "^6.3.0",
"vscode-languageserver-textdocument": "^1.0.11",
"vscode-uri": "^3.0.8"
},
"peerDependencies": {
"@volar/language-service": "~2.4.0"
},
"peerDependenciesMeta": {
"@volar/language-service": {
"optional": true
}
}
},
"node_modules/volar-service-emmet": {
"version": "0.0.70",
"resolved": "https://registry.npmjs.org/volar-service-emmet/-/volar-service-emmet-0.0.70.tgz",
"integrity": "sha512-xi5bC4m/VyE3zy/n2CXspKeDZs3qA41tHLTw275/7dNWM/RqE2z3BnDICQybHIVp/6G1iOQj5c1qXMgQC08TNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@emmetio/css-parser": "^0.4.1",
"@emmetio/html-matcher": "^1.3.0",
"@vscode/emmet-helper": "^2.9.3",
"vscode-uri": "^3.0.8"
},
"peerDependencies": {
"@volar/language-service": "~2.4.0"
},
"peerDependenciesMeta": {
"@volar/language-service": {
"optional": true
}
}
},
"node_modules/volar-service-html": {
"version": "0.0.70",
"resolved": "https://registry.npmjs.org/volar-service-html/-/volar-service-html-0.0.70.tgz",
"integrity": "sha512-eR6vCgMdmYAo4n+gcT7DSyBQbwB8S3HZZvSagTf0sxNaD4WppMCFfpqWnkrlGStPKMZvMiejRRVmqsX9dYcTvQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"vscode-html-languageservice": "^5.3.0",
"vscode-languageserver-textdocument": "^1.0.11",
"vscode-uri": "^3.0.8"
},
"peerDependencies": {
"@volar/language-service": "~2.4.0"
},
"peerDependenciesMeta": {
"@volar/language-service": {
"optional": true
}
}
},
"node_modules/volar-service-prettier": {
"version": "0.0.70",
"resolved": "https://registry.npmjs.org/volar-service-prettier/-/volar-service-prettier-0.0.70.tgz",
"integrity": "sha512-Z6BCFSpGVCd8BPAsZ785Kce1BGlWd5ODqmqZGVuB14MJvrR4+CYz6cDy4F+igmE1gMifqfvMhdgT8Aud4M5ngg==",
"dev": true,
"license": "MIT",
"dependencies": {
"vscode-uri": "^3.0.8"
},
"peerDependencies": {
"@volar/language-service": "~2.4.0",
"prettier": "^2.2 || ^3.0"
},
"peerDependenciesMeta": {
"@volar/language-service": {
"optional": true
},
"prettier": {
"optional": true
}
}
},
"node_modules/volar-service-typescript": {
"version": "0.0.70",
"resolved": "https://registry.npmjs.org/volar-service-typescript/-/volar-service-typescript-0.0.70.tgz",
"integrity": "sha512-l46Bx4cokkUedTd74ojO5H/zqHZJ8SUuyZ0IB8JN4jfRqUM3bQFBHoOwlZCyZmOeO0A3RQNkMnFclxO4c++gsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-browserify": "^1.0.1",
"semver": "^7.6.2",
"typescript-auto-import-cache": "^0.3.5",
"vscode-languageserver-textdocument": "^1.0.11",
"vscode-nls": "^5.2.0",
"vscode-uri": "^3.0.8"
},
"peerDependencies": {
"@volar/language-service": "~2.4.0"
},
"peerDependenciesMeta": {
"@volar/language-service": {
"optional": true
}
}
},
"node_modules/volar-service-typescript-twoslash-queries": {
"version": "0.0.70",
"resolved": "https://registry.npmjs.org/volar-service-typescript-twoslash-queries/-/volar-service-typescript-twoslash-queries-0.0.70.tgz",
"integrity": "sha512-IdD13Z9N2Bu8EM6CM0fDV1E69olEYGHDU25X51YXmq8Y0CmJ2LNj6gOiBJgpS5JGUqFzECVhMNBW7R0sPdRTMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"vscode-uri": "^3.0.8"
},
"peerDependencies": {
"@volar/language-service": "~2.4.0"
},
"peerDependenciesMeta": {
"@volar/language-service": {
"optional": true
}
}
},
"node_modules/volar-service-typescript/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/volar-service-yaml": {
"version": "0.0.70",
"resolved": "https://registry.npmjs.org/volar-service-yaml/-/volar-service-yaml-0.0.70.tgz",
"integrity": "sha512-0c8bXDBeoATF9F6iPIlOuYTuZAC4c+yi0siQo920u7eiBJk8oQmUmg9cDUbR4+Gl++bvGP4plj3fErbJuPqdcQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"vscode-uri": "^3.0.8",
"yaml-language-server": "~1.20.0"
},
"peerDependencies": {
"@volar/language-service": "~2.4.0"
},
"peerDependenciesMeta": {
"@volar/language-service": {
"optional": true
}
}
},
"node_modules/vscode-css-languageservice": {
"version": "6.3.10",
"resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-6.3.10.tgz",
"integrity": "sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vscode/l10n": "^0.0.18",
"vscode-languageserver-textdocument": "^1.0.12",
"vscode-languageserver-types": "3.17.5",
"vscode-uri": "^3.1.0"
}
},
"node_modules/vscode-html-languageservice": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-5.6.2.tgz",
"integrity": "sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vscode/l10n": "^0.0.18",
"vscode-languageserver-textdocument": "^1.0.12",
"vscode-languageserver-types": "^3.17.5",
"vscode-uri": "^3.1.0"
}
},
"node_modules/vscode-json-languageservice": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-4.1.8.tgz",
"integrity": "sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg==",
"dev": true,
"license": "MIT",
"dependencies": {
"jsonc-parser": "^3.0.0",
"vscode-languageserver-textdocument": "^1.0.1",
"vscode-languageserver-types": "^3.16.0",
"vscode-nls": "^5.0.0",
"vscode-uri": "^3.0.2"
},
"engines": {
"npm": ">=7.0.0"
}
},
"node_modules/vscode-json-languageservice/node_modules/jsonc-parser": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
"dev": true,
"license": "MIT"
},
"node_modules/vscode-jsonrpc": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
"integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/vscode-languageserver": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz",
"integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==",
"dev": true,
"license": "MIT",
"dependencies": {
"vscode-languageserver-protocol": "3.17.5"
},
"bin": {
"installServerIntoExtension": "bin/installServerIntoExtension"
}
},
"node_modules/vscode-languageserver-protocol": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
"integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
"dev": true,
"license": "MIT",
"dependencies": {
"vscode-jsonrpc": "8.2.0",
"vscode-languageserver-types": "3.17.5"
}
},
"node_modules/vscode-languageserver-textdocument": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz",
"integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==",
"dev": true,
"license": "MIT"
},
"node_modules/vscode-languageserver-types": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
"dev": true,
"license": "MIT"
},
"node_modules/vscode-nls": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-5.2.0.tgz",
"integrity": "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==",
"dev": true,
"license": "MIT"
},
"node_modules/vscode-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
"dev": true,
"license": "MIT"
},
"node_modules/web-namespaces": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
@@ -13044,6 +13641,91 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"devOptional": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yaml-language-server": {
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/yaml-language-server/-/yaml-language-server-1.20.0.tgz",
"integrity": "sha512-qhjK/bzSRZ6HtTvgeFvjNPJGWdZ0+x5NREV/9XZWFjIGezew2b4r5JPy66IfOhd5OA7KeFwk1JfmEbnTvev0cA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vscode/l10n": "^0.0.18",
"ajv": "^8.17.1",
"ajv-draft-04": "^1.0.0",
"prettier": "^3.5.0",
"request-light": "^0.5.7",
"vscode-json-languageservice": "4.1.8",
"vscode-languageserver": "^9.0.0",
"vscode-languageserver-textdocument": "^1.0.1",
"vscode-languageserver-types": "^3.16.0",
"vscode-uri": "^3.0.2",
"yaml": "2.7.1"
},
"bin": {
"yaml-language-server": "bin/yaml-language-server"
}
},
"node_modules/yaml-language-server/node_modules/ajv": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/yaml-language-server/node_modules/ajv-draft-04": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz",
"integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"ajv": "^8.5.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/yaml-language-server/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
},
"node_modules/yaml-language-server/node_modules/request-light": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/request-light/-/request-light-0.5.8.tgz",
"integrity": "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==",
"dev": true,
"license": "MIT"
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",

View File

@@ -33,7 +33,21 @@
"tailwindcss": "^4.2.1",
"tw-animate-css": "^1.4.0"
},
"overrides": {
"@modelcontextprotocol/sdk": {
"@hono/node-server": "1.19.13",
"hono": "4.12.12"
},
"router": {
"path-to-regexp": "8.4.2"
},
"defu": "6.1.7",
"vite": "6.4.2",
"brace-expansion": "1.1.13",
"yaml": "2.8.3"
},
"devDependencies": {
"@astrojs/check": "^0.9.8",
"@eslint/js": "^9.39.4",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",

View File

@@ -177,11 +177,7 @@ warn_command_conflict() {
step "Run now: export PATH=\"$INSTALL_BIN_DIR:\$PATH\" && hash -r && feynman"
step "Or launch directly: $expected_path"
case "$resolved_path" in
*"/node_modules/@companion-ai/feynman/"* | *"/node_modules/.bin/feynman")
step "If that path is an old global npm install, remove it with: npm uninstall -g @companion-ai/feynman"
;;
esac
step "If that path is an old package-manager install, remove it or put $INSTALL_BIN_DIR first on PATH."
fi
}
@@ -264,8 +260,8 @@ This usually means the release exists, but not all platform bundles were uploade
Workarounds:
- try again after the release finishes publishing
- install via pnpm instead: pnpm add -g @companion-ai/feynman
- install via bun instead: bun add -g @companion-ai/feynman
- pass the latest published version explicitly, e.g.:
curl -fsSL https://feynman.is/install | bash -s -- 0.2.16
EOF
exit 1
fi

View File

@@ -146,15 +146,17 @@ archive_metadata="$(resolve_version)"
resolved_version="$(printf '%s\n' "$archive_metadata" | sed -n '1p')"
git_ref="$(printf '%s\n' "$archive_metadata" | sed -n '2p')"
archive_url=""
case "$git_ref" in
archive_url="${FEYNMAN_INSTALL_SKILLS_ARCHIVE_URL:-}"
if [ -z "$archive_url" ]; then
case "$git_ref" in
main)
archive_url="https://github.com/getcompanion-ai/feynman/archive/refs/heads/main.tar.gz"
;;
v*)
archive_url="https://github.com/getcompanion-ai/feynman/archive/refs/tags/${git_ref}.tar.gz"
;;
esac
esac
fi
if [ -z "$archive_url" ]; then
echo "Could not resolve a download URL for ref: $git_ref" >&2
@@ -181,8 +183,8 @@ step "Extracting skills"
tar -xzf "$archive_path" -C "$extract_dir"
source_root="$(find "$extract_dir" -mindepth 1 -maxdepth 1 -type d | head -n 1)"
if [ -z "$source_root" ] || [ ! -d "$source_root/skills" ]; then
echo "Could not find skills/ in downloaded archive." >&2
if [ -z "$source_root" ] || [ ! -d "$source_root/skills" ] || [ ! -d "$source_root/prompts" ]; then
echo "Could not find the bundled skills resources in the downloaded archive." >&2
exit 1
fi
@@ -190,6 +192,10 @@ mkdir -p "$(dirname "$install_dir")"
rm -rf "$install_dir"
mkdir -p "$install_dir"
cp -R "$source_root/skills/." "$install_dir/"
mkdir -p "$install_dir/prompts"
cp -R "$source_root/prompts/." "$install_dir/prompts/"
cp "$source_root/AGENTS.md" "$install_dir/AGENTS.md"
cp "$source_root/CONTRIBUTING.md" "$install_dir/CONTRIBUTING.md"
step "Installed skills to $install_dir"
case "$SCOPE" in

View File

@@ -46,7 +46,7 @@ function Resolve-VersionMetadata {
return [PSCustomObject]@{
ResolvedVersion = $resolvedVersion
GitRef = "v$resolvedVersion"
DownloadUrl = "https://github.com/getcompanion-ai/feynman/archive/refs/tags/v$resolvedVersion.zip"
DownloadUrl = if ($env:FEYNMAN_INSTALL_SKILLS_ARCHIVE_URL) { $env:FEYNMAN_INSTALL_SKILLS_ARCHIVE_URL } else { "https://github.com/getcompanion-ai/feynman/archive/refs/tags/v$resolvedVersion.zip" }
}
}
@@ -92,8 +92,9 @@ try {
}
$skillsSource = Join-Path $sourceRoot.FullName "skills"
if (-not (Test-Path $skillsSource)) {
throw "Could not find skills/ in downloaded archive."
$promptsSource = Join-Path $sourceRoot.FullName "prompts"
if (-not (Test-Path $skillsSource) -or -not (Test-Path $promptsSource)) {
throw "Could not find the bundled skills resources in the downloaded archive."
}
$installParent = Split-Path $installDir -Parent
@@ -107,6 +108,10 @@ try {
New-Item -ItemType Directory -Path $installDir -Force | Out-Null
Copy-Item -Path (Join-Path $skillsSource "*") -Destination $installDir -Recurse -Force
New-Item -ItemType Directory -Path (Join-Path $installDir "prompts") -Force | Out-Null
Copy-Item -Path (Join-Path $promptsSource "*") -Destination (Join-Path $installDir "prompts") -Recurse -Force
Copy-Item -Path (Join-Path $sourceRoot.FullName "AGENTS.md") -Destination (Join-Path $installDir "AGENTS.md") -Force
Copy-Item -Path (Join-Path $sourceRoot.FullName "CONTRIBUTING.md") -Destination (Join-Path $installDir "CONTRIBUTING.md") -Force
Write-Host "==> Installed skills to $installDir"
if ($Scope -eq "Repo") {

View File

@@ -109,8 +109,8 @@ This usually means the release exists, but not all platform bundles were uploade
Workarounds:
- try again after the release finishes publishing
- install via pnpm instead: pnpm add -g @companion-ai/feynman
- install via bun instead: bun add -g @companion-ai/feynman
- pass the latest published version explicitly, e.g.:
& ([scriptblock]::Create((irm https://feynman.is/install.ps1))) -Version 0.2.16
"@
}
@@ -125,12 +125,18 @@ Workarounds:
New-Item -ItemType Directory -Path $installBinDir -Force | Out-Null
$shimPath = Join-Path $installBinDir "feynman.cmd"
$shimPs1Path = Join-Path $installBinDir "feynman.ps1"
Write-Host "==> Linking feynman into $installBinDir"
@"
@echo off
"$bundleDir\feynman.cmd" %*
CALL "$bundleDir\feynman.cmd" %*
"@ | Set-Content -Path $shimPath -Encoding ASCII
@"
`$BundleDir = "$bundleDir"
& "`$BundleDir\node\node.exe" "`$BundleDir\app\bin\feynman.js" @args
"@ | Set-Content -Path $shimPs1Path -Encoding UTF8
$currentUserPath = [Environment]::GetEnvironmentVariable("Path", "User")
$alreadyOnPath = $false
if ($currentUserPath) {
@@ -153,9 +159,7 @@ Workarounds:
Write-Warning "Current shell resolves feynman to $($resolvedCommand.Source)"
Write-Host "Run in a new shell, or run: `$env:Path = '$installBinDir;' + `$env:Path"
Write-Host "Then run: feynman"
if ($resolvedCommand.Source -like "*node_modules*@companion-ai*feynman*") {
Write-Host "If that path is an old global npm install, remove it with: npm uninstall -g @companion-ai/feynman"
}
Write-Host "If that path is an old package-manager install, remove it or put $installBinDir first on PATH."
}
Write-Host "Feynman $resolvedVersion installed successfully."

View File

@@ -46,4 +46,4 @@ function Badge({
)
}
export { Badge, badgeVariants }
export { Badge }

View File

@@ -64,4 +64,4 @@ function Button({
)
}
export { Button, buttonVariants }
export { Button }

View File

@@ -41,6 +41,36 @@ To see all models you have configured:
feynman model list
```
Only authenticated/configured providers appear in `feynman model list`. If you only see OpenAI models, it usually means only OpenAI auth is configured so far.
To add another provider, authenticate it first:
```bash
feynman model login anthropic
feynman model login google
```
Then switch the default model:
```bash
feynman model set anthropic/claude-opus-4-6
```
## Subagent model overrides
Feynman's bundled subagents inherit the main default model unless you override them explicitly. Inside the REPL, run:
```bash
/feynman-model
```
This opens an interactive picker where you can either:
- change the main default model for the session environment
- assign a different model to a specific bundled subagent such as `researcher`, `reviewer`, `writer`, or `verifier`
Per-subagent overrides are persisted in the synced agent files under `~/.feynman/agent/agents/` with a `model:` frontmatter field. Removing that field makes the subagent inherit the main default model again.
## Thinking levels
The `thinkingLevel` field controls how much reasoning the model does before responding. Available levels are `off`, `minimal`, `low`, `medium`, `high`, and `xhigh`. Higher levels produce more thorough analysis at the cost of latency and token usage. You can override per-session:

View File

@@ -1,11 +1,11 @@
---
title: Installation
description: Install Feynman on macOS, Linux, or Windows using curl, pnpm, or bun.
description: Install Feynman on macOS, Linux, or Windows using the standalone installer.
section: Getting Started
order: 1
---
Feynman ships as a standalone runtime bundle for macOS, Linux, and Windows, and as a package-manager install for environments where Node.js is already installed. The recommended approach is the one-line installer, which downloads a prebuilt native bundle with zero external runtime dependencies.
Feynman ships as a standalone runtime bundle for macOS, Linux, and Windows. The one-line installer downloads a prebuilt native bundle with zero external runtime dependencies.
## One-line installer (recommended)
@@ -17,7 +17,7 @@ curl -fsSL https://feynman.is/install | bash
The installer detects your OS and architecture automatically. On macOS it supports both Intel and Apple Silicon. On Linux it supports x64 and arm64. The launcher is installed to `~/.local/bin`, the bundled runtime is unpacked into `~/.local/share/feynman`, and your `PATH` is updated when needed.
If you previously installed Feynman via `npm`, `pnpm`, or `bun` and still see local Node.js errors after a curl install, your shell is probably still resolving the older global binary first. Run `which -a feynman`, then `hash -r`, or launch the standalone shim directly with `~/.local/bin/feynman`.
If you previously installed Feynman through a package manager and still see local Node.js errors after a curl install, your shell is probably still resolving the older global binary first. Run `which -a feynman`, then `hash -r`, or launch the standalone shim directly with `~/.local/bin/feynman`.
On **Windows**, open PowerShell as Administrator and run:
@@ -55,52 +55,22 @@ Or install them repo-locally:
& ([scriptblock]::Create((irm https://feynman.is/install-skills.ps1))) -Scope Repo
```
These installers download only the `skills/` tree from the Feynman repository. They do not install the Feynman terminal, bundled Node runtime, auth storage, or Pi packages.
These installers download the bundled `skills/` and `prompts/` trees plus the repo guidance files referenced by those skills. They do not install the Feynman terminal, bundled Node runtime, auth storage, or Pi packages.
## Pinned releases
The one-line installer already targets the latest tagged release. To pin an exact version, pass it explicitly:
```bash
curl -fsSL https://feynman.is/install | bash -s -- 0.2.16
curl -fsSL https://feynman.is/install | bash -s -- 0.2.17
```
On Windows:
```powershell
& ([scriptblock]::Create((irm https://feynman.is/install.ps1))) -Version 0.2.16
& ([scriptblock]::Create((irm https://feynman.is/install.ps1))) -Version 0.2.17
```
## pnpm
If you already have Node.js `20.19.0` or newer installed, you can install Feynman globally via `pnpm`:
```bash
pnpm add -g @companion-ai/feynman
```
Or run it directly without installing:
```bash
pnpm dlx @companion-ai/feynman
```
## bun
`bun add -g` and `bunx` still use your local Node runtime for Feynman itself, so the same Node.js `20.19.0+` requirement applies.
```bash
bun add -g @companion-ai/feynman
```
Or run it directly without installing:
```bash
bunx @companion-ai/feynman
```
Both package-manager distributions ship the same core application but depend on Node.js being present on your system. The standalone installer is preferred because it bundles its own Node runtime and works without a separate Node installation.
## Post-install setup
After installation, run the guided setup wizard to configure your model provider and API keys:

View File

@@ -42,6 +42,33 @@ For API key providers, you are prompted to paste your key directly:
Keys are encrypted at rest and never sent anywhere except the provider's API endpoint.
### Local models: Ollama, LM Studio, vLLM
If you want to use a model running locally, choose the API-key flow and then select:
```text
Custom provider (baseUrl + API key)
```
For Ollama, the typical settings are:
```text
API mode: openai-completions
Base URL: http://localhost:11434/v1
Authorization header: No
Model ids: llama3.1:8b
API key: local
```
That same custom-provider flow also works for other OpenAI-compatible local servers such as LM Studio or vLLM. After saving the provider, run:
```bash
feynman model list
feynman model set <provider>/<model-id>
```
to confirm the local model is available and make it the default.
## Stage 3: Optional packages
Feynman's core ships with the essentials, but some features require additional packages. The wizard asks if you want to install optional presets:

View File

@@ -30,6 +30,7 @@ These are the primary commands you will use day-to-day. Each workflow dispatches
| `/log` | Write a durable session log with completed work, findings, open questions, and next steps |
| `/jobs` | Inspect active background work: running processes, scheduled follow-ups, and active watches |
| `/help` | Show grouped Feynman commands and prefill the editor with a selected command |
| `/feynman-model` | Open the model picker for the main default model and per-subagent overrides |
| `/init` | Bootstrap `AGENTS.md` and session-log folders for a new research project |
| `/outputs` | Browse all research artifacts (papers, outputs, experiments, notes) |
| `/search` | Search prior session transcripts for past research and findings |
@@ -37,6 +38,8 @@ These are the primary commands you will use day-to-day. Each workflow dispatches
Session management commands help you organize ongoing work. The `/log` command is particularly useful at the end of a research session to capture what was accomplished and what remains.
The `/feynman-model` command opens an interactive picker that lets you either change the main default model or assign a different model to a bundled subagent like `researcher`, `reviewer`, `writer`, or `verifier`.
## Running workflows from the CLI
All research workflow slash commands can also be run directly from the command line:

View File

@@ -45,8 +45,6 @@ const terminalCommands = [
const installCommands = [
{ label: "curl", command: "curl -fsSL https://feynman.is/install | bash" },
{ label: "pnpm", command: "pnpm add -g @companion-ai/feynman" },
{ label: "bun", command: "bun add -g @companion-ai/feynman" },
]
---