6 Commits

Author SHA1 Message Date
Advait Paliwal
d30506c82a Link bundled runtime dependencies for core packages 2026-04-16 15:56:53 -07:00
Advait Paliwal
c3f7f6ec08 Add LM Studio setup and blocked research artifacts 2026-04-16 15:39:01 -07:00
Advait Paliwal
d2570188f9 Add first-class LM Studio setup 2026-04-16 15:34:32 -07:00
Advait Paliwal
ca559dfd91 Fix extension repair and add Opus 4.7 overlay 2026-04-16 14:05:17 -07:00
Advait Paliwal
46b2aa93d0 Skip release when npm version already exists 2026-04-15 23:15:27 -07:00
Advait Paliwal
043e241464 Deduplicate fabricated-results guardrails 2026-04-15 22:53:38 -07:00
22 changed files with 371 additions and 50 deletions

View File

@@ -15,6 +15,8 @@ Operating rules:
- Never answer a latest/current question from arXiv or alpha-backed paper search alone. - Never answer a latest/current question from arXiv or alpha-backed paper search alone.
- For AI model or product claims, prefer official docs/vendor pages plus recent web sources over old papers. - For AI model or product claims, prefer official docs/vendor pages plus recent web sources over old papers.
- Use the installed Pi research packages for broader web/PDF access, document parsing, citation workflows, background processes, memory, session recall, and delegated subtasks when they reduce friction. - Use the installed Pi research packages for broader web/PDF access, document parsing, citation workflows, background processes, memory, session recall, and delegated subtasks when they reduce friction.
- You are running inside the Feynman/Pi runtime with filesystem tools, package tools, and configured extensions. Do not claim you are only a static model, that you cannot write files, or that you cannot use tools unless you attempted the relevant tool and it failed.
- If a tool, package, source, or network route is unavailable, record the specific failed capability and still write the requested durable artifact with a clear `Blocked / Unverified` status instead of stopping with chat-only prose.
- Feynman ships project subagents for research work. Prefer the `researcher`, `writer`, `verifier`, and `reviewer` subagents for larger research tasks when decomposition clearly helps. - Feynman ships project subagents for research work. Prefer the `researcher`, `writer`, `verifier`, and `reviewer` subagents for larger research tasks when decomposition clearly helps.
- Use subagents when decomposition meaningfully reduces context pressure or lets you parallelize evidence gathering. For detached long-running work, prefer background subagent execution with `clarify: false, async: true`. - Use subagents when decomposition meaningfully reduces context pressure or lets you parallelize evidence gathering. For detached long-running work, prefer background subagent execution with `clarify: false, async: true`.
- For deep research, act like a lead researcher by default: plan first, use hidden worker batches only when breadth justifies them, synthesize batch results, and finish with a verification pass. - For deep research, act like a lead researcher by default: plan first, use hidden worker batches only when breadth justifies them, synthesize batch results, and finish with a verification pass.
@@ -44,6 +46,7 @@ Operating rules:
- When citing papers from alpha-backed tools, prefer direct arXiv or alphaXiv links and include the arXiv ID. - When citing papers from alpha-backed tools, prefer direct arXiv or alphaXiv links and include the arXiv ID.
- Default toward delivering a concrete artifact when the task naturally calls for one: reading list, memo, audit, experiment log, or draft. - Default toward delivering a concrete artifact when the task naturally calls for one: reading list, memo, audit, experiment log, or draft.
- For user-facing workflows, produce exactly one canonical durable Markdown artifact unless the user explicitly asks for multiple deliverables. - For user-facing workflows, produce exactly one canonical durable Markdown artifact unless the user explicitly asks for multiple deliverables.
- If a workflow requests a durable artifact, verify the file exists on disk before the final response. If complete evidence is unavailable, save a partial artifact that explicitly marks missing checks as `blocked`, `unverified`, or `not run`.
- Do not create extra user-facing intermediate markdown files just because the workflow has multiple reasoning stages. - Do not create extra user-facing intermediate markdown files just because the workflow has multiple reasoning stages.
- Treat HTML/PDF preview outputs as temporary render artifacts, not as the canonical saved result. - Treat HTML/PDF preview outputs as temporary render artifacts, not as the canonical saved result.
- Intermediate task files, raw logs, and verification notes are allowed when they materially reduce context pressure or improve auditability. - Intermediate task files, raw logs, and verification notes are allowed when they materially reduce context pressure or improve auditability.

View File

@@ -17,7 +17,7 @@ You receive a draft document and the research files it was built from. Your job
4. **Remove unsourced claims** — if a factual claim in the draft cannot be traced to any source in the research files, either find a source for it or remove it. Do not leave unsourced factual claims. 4. **Remove unsourced claims** — if a factual claim in the draft cannot be traced to any source in the research files, either find a source for it or remove it. Do not leave unsourced factual claims.
5. **Verify meaning, not just topic overlap.** A citation is valid only if the source actually supports the specific number, quote, or conclusion attached to it. 5. **Verify meaning, not just topic overlap.** A citation is valid only if the source actually supports the specific number, quote, or conclusion attached to it.
6. **Refuse fake certainty.** Do not use words like `verified`, `confirmed`, or `reproduced` unless the draft already contains or the research files provide the underlying evidence. 6. **Refuse fake certainty.** Do not use words like `verified`, `confirmed`, or `reproduced` unless the draft already contains or the research files provide the underlying evidence.
7. **Never invent or keep fabricated results.** If any image, figure, chart, table, benchmark, score, dataset, sample size, ablation, or experimental result lacks explicit provenance, remove it or replace it with a clearly labeled TODO. Never keep a made-up result because it “looks plausible.” 7. **Enforce the system prompt's provenance rule.** Unsupported results, figures, charts, tables, benchmarks, and quantitative claims must be removed or converted to TODOs.
## Citation rules ## Citation rules
@@ -41,7 +41,7 @@ For code-backed or quantitative claims:
- Treat captions such as “illustrative,” “simulated,” “representative,” or “example” as insufficient unless the user explicitly requested synthetic/example data. Otherwise remove the visual and mark the missing experiment. - Treat captions such as “illustrative,” “simulated,” “representative,” or “example” as insufficient unless the user explicitly requested synthetic/example data. Otherwise remove the visual and mark the missing experiment.
- Do not preserve polished summaries that outrun the raw evidence. - Do not preserve polished summaries that outrun the raw evidence.
## Fabrication audit ## Result provenance audit
Before saving the final document, scan for: Before saving the final document, scan for:
- numeric scores or percentages, - numeric scores or percentages,

View File

@@ -15,7 +15,7 @@ You are Feynman's writing subagent.
3. **Be explicit about gaps.** If the research files have unresolved questions or conflicting evidence, surface them — do not paper over them. 3. **Be explicit about gaps.** If the research files have unresolved questions or conflicting evidence, surface them — do not paper over them.
4. **Do not promote draft text into fact.** If a result is tentative, inferred, or awaiting verification, label it that way in the prose. 4. **Do not promote draft text into fact.** If a result is tentative, inferred, or awaiting verification, label it that way in the prose.
5. **No aesthetic laundering.** Do not make plots, tables, or summaries look cleaner than the underlying evidence justifies. 5. **No aesthetic laundering.** Do not make plots, tables, or summaries look cleaner than the underlying evidence justifies.
6. **Never fabricate results.** Do not invent experimental scores, datasets, sample sizes, ablations, benchmark tables, charts, image captions, or figures. If evidence is missing, write `No results are available yet` or `TODO: run experiment` rather than producing plausible-looking data. 6. **Follow the system prompt's provenance rule.** Missing results become gaps or TODOs, never plausible-looking data.
## Output structure ## Output structure
@@ -50,7 +50,7 @@ Unresolved issues, disagreements between sources, gaps in evidence.
- Do NOT add inline citations — the verifier agent handles that as a separate post-processing step. - Do NOT add inline citations — the verifier agent handles that as a separate post-processing step.
- Do NOT add a Sources section — the verifier agent builds that. - Do NOT add a Sources section — the verifier agent builds that.
- Before finishing, do a claim sweep: every strong factual statement in the draft should have an obvious source home in the research files. - Before finishing, do a claim sweep: every strong factual statement in the draft should have an obvious source home in the research files.
- Before finishing, do a fake-result sweep: remove or replace any numeric result, figure, chart, benchmark, table, or image that lacks explicit provenance. - Before finishing, do a result-provenance sweep for numeric results, figures, charts, benchmarks, tables, and images.
## Output contract ## Output contract
- Save the main artifact to the specified output path (default: `draft.md`). - Save the main artifact to the specified output path (default: `draft.md`).

View File

@@ -29,7 +29,8 @@ jobs:
run: | run: |
LOCAL=$(node -p "require('./package.json').version") LOCAL=$(node -p "require('./package.json').version")
echo "version=$LOCAL" >> "$GITHUB_OUTPUT" echo "version=$LOCAL" >> "$GITHUB_OUTPUT"
if gh release view "v$LOCAL" >/dev/null 2>&1; then PUBLISHED=$(npm view @companion-ai/feynman version 2>/dev/null || true)
if [ "$PUBLISHED" = "$LOCAL" ] || gh release view "v$LOCAL" >/dev/null 2>&1; then
echo "should_release=false" >> "$GITHUB_OUTPUT" echo "should_release=false" >> "$GITHUB_OUTPUT"
else else
echo "should_release=true" >> "$GITHUB_OUTPUT" echo "should_release=true" >> "$GITHUB_OUTPUT"

View File

@@ -25,7 +25,7 @@ curl -fsSL https://feynman.is/install | bash
irm https://feynman.is/install.ps1 | iex 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.20`. 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.24`.
The installer downloads a standalone native bundle with its own Node.js runtime. The installer downloads a standalone native bundle with its own Node.js runtime.
@@ -33,7 +33,7 @@ To upgrade the standalone app later, rerun the installer. `feynman update` only
To uninstall the standalone app, remove the launcher and runtime bundle, then optionally remove `~/.feynman` if you also want to delete settings, sessions, and installed package state. If you also want to delete alphaXiv login state, remove `~/.ahub`. See the installation guide for platform-specific paths. To uninstall the standalone app, remove the launcher and runtime bundle, then optionally remove `~/.feynman` if you also want to delete settings, sessions, and installed package state. If you also want to delete alphaXiv login state, remove `~/.ahub`. See the installation guide for platform-specific paths.
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`. Local models are supported through the setup flow. For LM Studio, run `feynman setup`, choose `LM Studio`, and keep the default `http://localhost:1234/v1` unless you changed the server port. For Ollama or vLLM, choose `Custom provider (baseUrl + API key)`, use `openai-completions`, and point it at the local `/v1` endpoint.
### Skills Only ### Skills Only

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@companion-ai/feynman", "name": "@companion-ai/feynman",
"version": "0.2.20", "version": "0.2.24",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@companion-ai/feynman", "name": "@companion-ai/feynman",
"version": "0.2.20", "version": "0.2.24",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@companion-ai/feynman", "name": "@companion-ai/feynman",
"version": "0.2.20", "version": "0.2.24",
"description": "Research-first CLI agent built on Pi and alphaXiv", "description": "Research-first CLI agent built on Pi and alphaXiv",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",

View File

@@ -53,6 +53,8 @@ Also save the plan with `memory_remember` (type: `fact`, key: `deepresearch.<slu
Present the plan to the user. If this is an unattended or one-shot run, continue automatically. If the user is actively interacting in the terminal, give them a brief chance to request plan changes before proceeding. Present the plan to the user. If this is an unattended or one-shot run, continue automatically. If the user is actively interacting in the terminal, give them a brief chance to request plan changes before proceeding.
Do not stop after planning. If live search, subagents, web access, alphaXiv, or any other capability is unavailable, continue in degraded mode and write a durable blocked/partial report that records exactly which capabilities failed.
## 2. Scale decision ## 2. Scale decision
| Query type | Execution | | Query type | Execution |
@@ -105,6 +107,13 @@ When the work spans multiple rounds, also append a concise chronological entry t
Most topics need 1-2 rounds. Stop when additional rounds would not materially change conclusions. Most topics need 1-2 rounds. Stop when additional rounds would not materially change conclusions.
If no researcher files can be produced because tools, subagents, or network access failed, create `outputs/.drafts/<slug>-draft.md` yourself as a blocked report with:
- what was requested,
- which capabilities failed,
- what evidence was and was not gathered,
- a proposed source-gathering plan,
- no invented sources or results.
## 5. Write the report ## 5. Write the report
Once evidence is sufficient, YOU write the full research brief directly. Do not delegate writing to another agent. Read the research files, synthesize the findings, and produce a complete document: Once evidence is sufficient, YOU write the full research brief directly. Do not delegate writing to another agent. Read the research files, synthesize the findings, and produce a complete document:
@@ -190,6 +199,7 @@ Before you stop, verify on disk that all of these exist:
- `outputs/<slug>.provenance.md` or `papers/<slug>.provenance.md` provenance sidecar - `outputs/<slug>.provenance.md` or `papers/<slug>.provenance.md` provenance sidecar
Do not stop at `<slug>-brief.md` alone. If the cited brief exists but the promoted final output or provenance sidecar does not, create them before responding. Do not stop at `<slug>-brief.md` alone. If the cited brief exists but the promoted final output or provenance sidecar does not, create them before responding.
If full verification could not be completed, still create the final deliverable and provenance sidecar with `Verification: BLOCKED` or `PASS WITH NOTES` and list the missing checks. Never end with only an explanation in chat.
## Background execution ## Background execution

View File

@@ -110,7 +110,7 @@ This usually means the release exists, but not all platform bundles were uploade
Workarounds: Workarounds:
- try again after the release finishes publishing - try again after the release finishes publishing
- pass the latest published version explicitly, e.g.: - pass the latest published version explicitly, e.g.:
& ([scriptblock]::Create((irm https://feynman.is/install.ps1))) -Version 0.2.20 & ([scriptblock]::Create((irm https://feynman.is/install.ps1))) -Version 0.2.24
"@ "@
} }

View File

@@ -261,7 +261,7 @@ This usually means the release exists, but not all platform bundles were uploade
Workarounds: Workarounds:
- try again after the release finishes publishing - try again after the release finishes publishing
- pass the latest published version explicitly, e.g.: - pass the latest published version explicitly, e.g.:
curl -fsSL https://feynman.is/install | bash -s -- 0.2.20 curl -fsSL https://feynman.is/install | bash -s -- 0.2.24
EOF EOF
exit 1 exit 1
fi fi

View File

@@ -1,5 +1,5 @@
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { delimiter, dirname, resolve } from "node:path"; import { delimiter, dirname, resolve } from "node:path";
@@ -260,6 +260,23 @@ function ensureParentDir(path) {
mkdirSync(dirname(path), { recursive: true }); mkdirSync(dirname(path), { recursive: true });
} }
function packageDependencyExists(packagePath, globalNodeModulesRoot, dependency) {
return existsSync(resolve(packagePath, "node_modules", dependency)) ||
existsSync(resolve(globalNodeModulesRoot, dependency));
}
function installedPackageLooksUsable(packagePath, globalNodeModulesRoot) {
if (!existsSync(resolve(packagePath, "package.json"))) return false;
try {
const pkg = JSON.parse(readFileSync(resolve(packagePath, "package.json"), "utf8"));
return Object.keys(pkg.dependencies ?? {}).every((dependency) =>
packageDependencyExists(packagePath, globalNodeModulesRoot, dependency)
);
} catch {
return false;
}
}
function linkPointsTo(linkPath, targetPath) { function linkPointsTo(linkPath, targetPath) {
try { try {
if (!lstatSync(linkPath).isSymbolicLink()) return false; if (!lstatSync(linkPath).isSymbolicLink()) return false;
@@ -269,26 +286,53 @@ function linkPointsTo(linkPath, targetPath) {
} }
} }
function ensureBundledPackageLinks(packageSpecs) { function listWorkspacePackageNames(root) {
if (!workspaceMatchesRuntime(packageSpecs)) return; if (!existsSync(root)) return [];
const names = [];
for (const entry of readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
if (entry.name.startsWith(".")) continue;
if (entry.name.startsWith("@")) {
const scopeRoot = resolve(root, entry.name);
for (const scopedEntry of readdirSync(scopeRoot, { withFileTypes: true })) {
if (!scopedEntry.isDirectory() && !scopedEntry.isSymbolicLink()) continue;
names.push(`${entry.name}/${scopedEntry.name}`);
}
continue;
}
names.push(entry.name);
}
return names;
}
for (const spec of packageSpecs) { function linkBundledPackage(packageName) {
const packageName = parsePackageName(spec);
const sourcePath = resolve(workspaceRoot, packageName); const sourcePath = resolve(workspaceRoot, packageName);
const targetPath = resolve(globalNodeModulesRoot, packageName); const targetPath = resolve(globalNodeModulesRoot, packageName);
if (!existsSync(sourcePath)) continue; if (!existsSync(sourcePath)) return false;
if (linkPointsTo(targetPath, sourcePath)) continue; if (linkPointsTo(targetPath, sourcePath)) return false;
try { try {
if (lstatSync(targetPath).isSymbolicLink()) { if (lstatSync(targetPath).isSymbolicLink()) {
rmSync(targetPath, { force: true }); rmSync(targetPath, { force: true });
} else if (!installedPackageLooksUsable(targetPath, globalNodeModulesRoot)) {
rmSync(targetPath, { recursive: true, force: true });
} }
} catch {} } catch {}
if (existsSync(targetPath)) continue; if (existsSync(targetPath)) return false;
ensureParentDir(targetPath); ensureParentDir(targetPath);
try { try {
symlinkSync(sourcePath, targetPath, process.platform === "win32" ? "junction" : "dir"); symlinkSync(sourcePath, targetPath, process.platform === "win32" ? "junction" : "dir");
} catch {} return true;
} catch {
return false;
}
}
function ensureBundledPackageLinks(packageSpecs) {
if (!workspaceMatchesRuntime(packageSpecs)) return;
for (const packageName of listWorkspacePackageNames(workspaceRoot)) {
linkBundledPackage(packageName);
} }
} }

View File

@@ -83,6 +83,7 @@ const API_KEY_PROVIDERS: ApiKeyProviderInfo[] = [
{ id: "openai", label: "OpenAI Platform API", envVar: "OPENAI_API_KEY" }, { id: "openai", label: "OpenAI Platform API", envVar: "OPENAI_API_KEY" },
{ id: "anthropic", label: "Anthropic API", envVar: "ANTHROPIC_API_KEY" }, { id: "anthropic", label: "Anthropic API", envVar: "ANTHROPIC_API_KEY" },
{ id: "google", label: "Google Gemini API", envVar: "GEMINI_API_KEY" }, { id: "google", label: "Google Gemini API", envVar: "GEMINI_API_KEY" },
{ id: "lm-studio", label: "LM Studio (local OpenAI-compatible server)" },
{ id: "__custom__", label: "Custom provider (local/self-hosted/proxy)" }, { id: "__custom__", label: "Custom provider (local/self-hosted/proxy)" },
{ id: "amazon-bedrock", label: "Amazon Bedrock (AWS credential chain)" }, { id: "amazon-bedrock", label: "Amazon Bedrock (AWS credential chain)" },
{ id: "openrouter", label: "OpenRouter", envVar: "OPENROUTER_API_KEY" }, { id: "openrouter", label: "OpenRouter", envVar: "OPENROUTER_API_KEY" },
@@ -132,6 +133,8 @@ async function selectApiKeyProvider(): Promise<ApiKeyProviderInfo | undefined> {
label: provider.label, label: provider.label,
hint: provider.id === "__custom__" hint: provider.id === "__custom__"
? "Ollama, vLLM, LM Studio, proxies" ? "Ollama, vLLM, LM Studio, proxies"
: provider.id === "lm-studio"
? "http://localhost:1234/v1"
: provider.envVar ?? provider.id, : provider.envVar ?? provider.id,
})); }));
options.push({ value: "cancel", label: "Cancel" }); options.push({ value: "cancel", label: "Cancel" });
@@ -362,6 +365,44 @@ async function promptCustomProviderSetup(): Promise<CustomProviderSetup | undefi
return { providerId, modelIds, baseUrl, api, apiKeyConfig, authHeader }; return { providerId, modelIds, baseUrl, api, apiKeyConfig, authHeader };
} }
async function promptLmStudioProviderSetup(): Promise<CustomProviderSetup | undefined> {
printSection("LM Studio");
printInfo("Start the LM Studio local server first, then load a model.");
const baseUrlRaw = await promptText("Base URL", "http://localhost:1234/v1");
const { baseUrl } = normalizeCustomProviderBaseUrl("openai-completions", baseUrlRaw);
if (!baseUrl) {
printWarning("Base URL is required.");
return undefined;
}
const detectedModelIds = await bestEffortFetchOpenAiModelIds(baseUrl, "lm-studio", false);
let modelIdsDefault = "local-model";
if (detectedModelIds && detectedModelIds.length > 0) {
const sample = detectedModelIds.slice(0, 10).join(", ");
printInfo(`Detected LM Studio models: ${sample}${detectedModelIds.length > 10 ? ", ..." : ""}`);
modelIdsDefault = detectedModelIds[0]!;
} else {
printInfo("No models detected from /models. Enter the exact model id shown in LM Studio.");
}
const modelIdsRaw = await promptText("Model id(s) (comma-separated)", modelIdsDefault);
const modelIds = normalizeModelIds(modelIdsRaw);
if (modelIds.length === 0) {
printWarning("At least one model id is required.");
return undefined;
}
return {
providerId: "lm-studio",
modelIds,
baseUrl,
api: "openai-completions",
apiKeyConfig: "lm-studio",
authHeader: false,
};
}
async function verifyCustomProvider(setup: CustomProviderSetup, authPath: string): Promise<void> { async function verifyCustomProvider(setup: CustomProviderSetup, authPath: string): Promise<void> {
const registry = createModelRegistry(authPath); const registry = createModelRegistry(authPath);
const modelsError = registry.getError(); const modelsError = registry.getError();
@@ -548,6 +589,31 @@ async function configureApiKeyProvider(authPath: string, providerId?: string): P
return configureBedrockProvider(authPath); return configureBedrockProvider(authPath);
} }
if (provider.id === "lm-studio") {
const setup = await promptLmStudioProviderSetup();
if (!setup) {
printInfo("LM Studio setup cancelled.");
return false;
}
const modelsJsonPath = getModelsJsonPath(authPath);
const result = upsertProviderConfig(modelsJsonPath, setup.providerId, {
baseUrl: setup.baseUrl,
apiKey: setup.apiKeyConfig,
api: setup.api,
authHeader: setup.authHeader,
models: setup.modelIds.map((id) => ({ id })),
});
if (!result.ok) {
printWarning(result.error);
return false;
}
printSuccess("Saved LM Studio provider.");
await verifyCustomProvider(setup, authPath);
return true;
}
if (provider.id === "__custom__") { if (provider.id === "__custom__") {
const setup = await promptCustomProviderSetup(); const setup = await promptCustomProviderSetup();
if (!setup) { if (!setup) {

View File

@@ -1,11 +1,41 @@
import { dirname, resolve } from "node:path"; import { dirname, resolve } from "node:path";
import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent"; import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
import { getModels } from "@mariozechner/pi-ai";
import { anthropicOAuthProvider } from "@mariozechner/pi-ai/oauth";
export function getModelsJsonPath(authPath: string): string { export function getModelsJsonPath(authPath: string): string {
return resolve(dirname(authPath), "models.json"); return resolve(dirname(authPath), "models.json");
} }
export function createModelRegistry(authPath: string): ModelRegistry { function registerFeynmanModelOverlays(modelRegistry: ModelRegistry): void {
return ModelRegistry.create(AuthStorage.create(authPath), getModelsJsonPath(authPath)); const anthropicModels = getModels("anthropic");
if (anthropicModels.some((model) => model.id === "claude-opus-4-7")) {
return;
}
const opus46 = anthropicModels.find((model) => model.id === "claude-opus-4-6");
if (!opus46) {
return;
}
modelRegistry.registerProvider("anthropic", {
baseUrl: "https://api.anthropic.com",
api: "anthropic-messages",
oauth: anthropicOAuthProvider,
models: [
...anthropicModels,
{
...opus46,
id: "claude-opus-4-7",
name: "Claude Opus 4.7",
},
],
});
}
export function createModelRegistry(authPath: string): ModelRegistry {
const registry = ModelRegistry.create(AuthStorage.create(authPath), getModelsJsonPath(authPath));
registerFeynmanModelOverlays(registry);
return registry;
} }

View File

@@ -1,5 +1,5 @@
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { cpSync, existsSync, lstatSync, mkdirSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path"; import { dirname, join, resolve } from "node:path";
@@ -423,6 +423,86 @@ function linkDirectory(linkPath: string, targetPath: string): void {
} }
} }
function packageNameToPath(root: string, packageName: string): string {
return resolve(root, packageName);
}
function listBundledWorkspacePackageNames(root: string): string[] {
if (!existsSync(root)) {
return [];
}
const names: string[] = [];
for (const entry of readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
if (entry.name.startsWith(".")) continue;
if (entry.name.startsWith("@")) {
const scopeRoot = resolve(root, entry.name);
for (const scopedEntry of readdirSync(scopeRoot, { withFileTypes: true })) {
if (!scopedEntry.isDirectory() && !scopedEntry.isSymbolicLink()) continue;
names.push(`${entry.name}/${scopedEntry.name}`);
}
continue;
}
names.push(entry.name);
}
return names;
}
function packageDependencyExists(packagePath: string, globalNodeModulesRoot: string, dependency: string): boolean {
return existsSync(packageNameToPath(resolve(packagePath, "node_modules"), dependency)) ||
existsSync(packageNameToPath(globalNodeModulesRoot, dependency));
}
function installedPackageLooksUsable(packagePath: string, globalNodeModulesRoot: string): boolean {
if (!existsSync(resolve(packagePath, "package.json"))) {
return false;
}
try {
const pkg = JSON.parse(readFileSync(resolve(packagePath, "package.json"), "utf8")) as {
dependencies?: Record<string, string>;
};
const dependencies = Object.keys(pkg.dependencies ?? {});
return dependencies.every((dependency) => packageDependencyExists(packagePath, globalNodeModulesRoot, dependency));
} catch {
return false;
}
}
function replaceBrokenPackageWithBundledCopy(targetPath: string, bundledPackagePath: string, globalNodeModulesRoot: string): boolean {
if (!existsSync(targetPath)) {
return false;
}
if (pathsMatchSymlinkTarget(targetPath, bundledPackagePath)) {
return false;
}
if (installedPackageLooksUsable(targetPath, globalNodeModulesRoot)) {
return false;
}
rmSync(targetPath, { recursive: true, force: true });
linkDirectory(targetPath, bundledPackagePath);
return true;
}
function seedBundledPackage(globalNodeModulesRoot: string, bundledNodeModulesRoot: string, packageName: string): boolean {
const bundledPackagePath = resolve(bundledNodeModulesRoot, packageName);
if (!existsSync(bundledPackagePath)) {
return false;
}
const targetPath = resolve(globalNodeModulesRoot, packageName);
if (replaceBrokenPackageWithBundledCopy(targetPath, bundledPackagePath, globalNodeModulesRoot)) {
return true;
}
if (!existsSync(targetPath)) {
linkDirectory(targetPath, bundledPackagePath);
return true;
}
return false;
}
export function seedBundledWorkspacePackages( export function seedBundledWorkspacePackages(
agentDir: string, agentDir: string,
appRoot: string, appRoot: string,
@@ -435,6 +515,10 @@ export function seedBundledWorkspacePackages(
const globalNodeModulesRoot = resolve(getFeynmanNpmPrefixPath(agentDir), "lib", "node_modules"); const globalNodeModulesRoot = resolve(getFeynmanNpmPrefixPath(agentDir), "lib", "node_modules");
const seeded: string[] = []; const seeded: string[] = [];
const bundledPackageNames = listBundledWorkspacePackageNames(bundledNodeModulesRoot);
for (const packageName of bundledPackageNames) {
seedBundledPackage(globalNodeModulesRoot, bundledNodeModulesRoot, packageName);
}
for (const source of sources) { for (const source of sources) {
if (shouldSkipNativeSource(source)) continue; if (shouldSkipNativeSource(source)) continue;
@@ -442,12 +526,8 @@ export function seedBundledWorkspacePackages(
const parsed = parseNpmSource(source); const parsed = parseNpmSource(source);
if (!parsed) continue; if (!parsed) continue;
const bundledPackagePath = resolve(bundledNodeModulesRoot, parsed.name);
if (!existsSync(bundledPackagePath)) continue;
const targetPath = resolve(globalNodeModulesRoot, parsed.name); const targetPath = resolve(globalNodeModulesRoot, parsed.name);
if (!existsSync(targetPath)) { if (pathsMatchSymlinkTarget(targetPath, resolve(bundledNodeModulesRoot, parsed.name))) {
linkDirectory(targetPath, bundledPackagePath);
seeded.push(source); seeded.push(source);
} }
} }

View File

@@ -39,15 +39,33 @@ test("research writing prompts forbid fabricated results and unproven figures",
for (const [label, content] of [ for (const [label, content] of [
["system prompt", systemPrompt], ["system prompt", systemPrompt],
["writer prompt", writerPrompt],
["verifier prompt", verifierPrompt],
] as const) { ] as const) {
assert.match(content, /Never (invent|fabricate)/i, `${label} must explicitly forbid invented or fabricated results`); assert.match(content, /Never (invent|fabricate)/i, `${label} must explicitly forbid invented or fabricated results`);
assert.match(content, /(figure|chart|image|table)/i, `${label} must cover visual/table provenance`); assert.match(content, /(figure|chart|image|table)/i, `${label} must cover visual/table provenance`);
assert.match(content, /(provenance|source|artifact|script|raw)/i, `${label} must require traceable support`); assert.match(content, /(provenance|source|artifact|script|raw)/i, `${label} must require traceable support`);
} }
for (const [label, content] of [
["writer prompt", writerPrompt],
["verifier prompt", verifierPrompt],
["draft prompt", draftPrompt],
] as const) {
assert.match(content, /system prompt.*provenance rule/i, `${label} must point back to the system provenance rule`);
}
assert.match(draftPrompt, /system prompt's provenance rules/i); assert.match(draftPrompt, /system prompt's provenance rules/i);
assert.match(draftPrompt, /placeholder or proposed experimental plan/i); assert.match(draftPrompt, /placeholder or proposed experimental plan/i);
assert.match(draftPrompt, /source-backed quantitative data/i); assert.match(draftPrompt, /source-backed quantitative data/i);
}); });
test("deepresearch workflow requires durable artifacts even when blocked", () => {
const systemPrompt = readFileSync(join(repoRoot, ".feynman", "SYSTEM.md"), "utf8");
const deepResearchPrompt = readFileSync(join(repoRoot, "prompts", "deepresearch.md"), "utf8");
assert.match(systemPrompt, /Do not claim you are only a static model/i);
assert.match(systemPrompt, /write the requested durable artifact/i);
assert.match(deepResearchPrompt, /Do not stop after planning/i);
assert.match(deepResearchPrompt, /degraded mode/i);
assert.match(deepResearchPrompt, /Verification: BLOCKED/i);
assert.match(deepResearchPrompt, /Never end with only an explanation in chat/i);
});

View File

@@ -7,6 +7,7 @@ import { join } from "node:path";
import { resolveInitialPrompt, shouldRunInteractiveSetup } from "../src/cli.js"; import { resolveInitialPrompt, shouldRunInteractiveSetup } from "../src/cli.js";
import { buildModelStatusSnapshotFromRecords, chooseRecommendedModel } from "../src/model/catalog.js"; import { buildModelStatusSnapshotFromRecords, chooseRecommendedModel } from "../src/model/catalog.js";
import { resolveModelProviderForCommand, setDefaultModelSpec } from "../src/model/commands.js"; import { resolveModelProviderForCommand, setDefaultModelSpec } from "../src/model/commands.js";
import { createModelRegistry } from "../src/model/registry.js";
function createAuthPath(contents: Record<string, unknown>): string { function createAuthPath(contents: Record<string, unknown>): string {
const root = mkdtempSync(join(tmpdir(), "feynman-auth-")); const root = mkdtempSync(join(tmpdir(), "feynman-auth-"));
@@ -26,6 +27,17 @@ test("chooseRecommendedModel prefers the strongest authenticated research model"
assert.equal(recommendation?.spec, "anthropic/claude-opus-4-6"); assert.equal(recommendation?.spec, "anthropic/claude-opus-4-6");
}); });
test("createModelRegistry overlays new Anthropic Opus model before upstream Pi updates", () => {
const authPath = createAuthPath({
anthropic: { type: "api_key", key: "anthropic-test-key" },
});
const registry = createModelRegistry(authPath);
assert.ok(registry.find("anthropic", "claude-opus-4-7"));
assert.equal(registry.getAvailable().some((model) => model.provider === "anthropic" && model.id === "claude-opus-4-7"), true);
});
test("setDefaultModelSpec accepts a unique bare model id from authenticated models", () => { test("setDefaultModelSpec accepts a unique bare model id from authenticated models", () => {
const authPath = createAuthPath({ const authPath = createAuthPath({
openai: { type: "api_key", key: "openai-test-key" }, openai: { type: "api_key", key: "openai-test-key" },
@@ -67,6 +79,15 @@ test("resolveModelProviderForCommand falls back to API-key providers when OAuth
assert.equal(resolved?.id, "google"); assert.equal(resolved?.id, "google");
}); });
test("resolveModelProviderForCommand supports LM Studio as a first-class local provider", () => {
const authPath = createAuthPath({});
const resolved = resolveModelProviderForCommand(authPath, "lm-studio");
assert.equal(resolved?.kind, "api-key");
assert.equal(resolved?.id, "lm-studio");
});
test("resolveModelProviderForCommand prefers OAuth when a provider supports both auth modes", () => { test("resolveModelProviderForCommand prefers OAuth when a provider supports both auth modes", () => {
const authPath = createAuthPath({}); const authPath = createAuthPath({});

View File

@@ -6,13 +6,17 @@ import { join, resolve } from "node:path";
import { installPackageSources, seedBundledWorkspacePackages, updateConfiguredPackages } from "../src/pi/package-ops.js"; import { installPackageSources, seedBundledWorkspacePackages, updateConfiguredPackages } from "../src/pi/package-ops.js";
function createBundledWorkspace(appRoot: string, packageNames: string[]): void { function createBundledWorkspace(
appRoot: string,
packageNames: string[],
dependenciesByPackage: Record<string, Record<string, string>> = {},
): void {
for (const packageName of packageNames) { for (const packageName of packageNames) {
const packageDir = resolve(appRoot, ".feynman", "npm", "node_modules", packageName); const packageDir = resolve(appRoot, ".feynman", "npm", "node_modules", packageName);
mkdirSync(packageDir, { recursive: true }); mkdirSync(packageDir, { recursive: true });
writeFileSync( writeFileSync(
join(packageDir, "package.json"), join(packageDir, "package.json"),
JSON.stringify({ name: packageName, version: "1.0.0" }, null, 2) + "\n", JSON.stringify({ name: packageName, version: "1.0.0", dependencies: dependenciesByPackage[packageName] }, null, 2) + "\n",
"utf8", "utf8",
); );
} }
@@ -76,6 +80,34 @@ test("seedBundledWorkspacePackages preserves existing installed packages", () =>
assert.equal(lstatSync(existingPackageDir).isSymbolicLink(), false); assert.equal(lstatSync(existingPackageDir).isSymbolicLink(), false);
}); });
test("seedBundledWorkspacePackages repairs broken existing bundled packages", () => {
const appRoot = mkdtempSync(join(tmpdir(), "feynman-bundle-"));
const homeRoot = mkdtempSync(join(tmpdir(), "feynman-home-"));
const agentDir = resolve(homeRoot, "agent");
const existingPackageDir = resolve(homeRoot, "npm-global", "lib", "node_modules", "pi-markdown-preview");
mkdirSync(agentDir, { recursive: true });
createBundledWorkspace(appRoot, ["pi-markdown-preview", "puppeteer-core"], {
"pi-markdown-preview": { "puppeteer-core": "^24.0.0" },
});
mkdirSync(existingPackageDir, { recursive: true });
writeFileSync(
resolve(existingPackageDir, "package.json"),
JSON.stringify({ name: "pi-markdown-preview", version: "broken", dependencies: { "puppeteer-core": "^24.0.0" } }) + "\n",
"utf8",
);
const seeded = seedBundledWorkspacePackages(agentDir, appRoot, ["npm:pi-markdown-preview"]);
assert.deepEqual(seeded, ["npm:pi-markdown-preview"]);
assert.equal(lstatSync(existingPackageDir).isSymbolicLink(), true);
assert.equal(lstatSync(resolve(homeRoot, "npm-global", "lib", "node_modules", "puppeteer-core")).isSymbolicLink(), true);
assert.equal(
readFileSync(resolve(existingPackageDir, "package.json"), "utf8").includes('"version": "1.0.0"'),
true,
);
});
test("installPackageSources filters noisy npm chatter but preserves meaningful output", async () => { test("installPackageSources filters noisy npm chatter but preserves meaningful output", async () => {
const root = mkdtempSync(join(tmpdir(), "feynman-package-ops-")); const root = mkdtempSync(join(tmpdir(), "feynman-package-ops-"));
const workingDir = resolve(root, "project"); const workingDir = resolve(root, "project");

View File

@@ -261,7 +261,7 @@ This usually means the release exists, but not all platform bundles were uploade
Workarounds: Workarounds:
- try again after the release finishes publishing - try again after the release finishes publishing
- pass the latest published version explicitly, e.g.: - pass the latest published version explicitly, e.g.:
curl -fsSL https://feynman.is/install | bash -s -- 0.2.20 curl -fsSL https://feynman.is/install | bash -s -- 0.2.24
EOF EOF
exit 1 exit 1
fi fi

View File

@@ -110,7 +110,7 @@ This usually means the release exists, but not all platform bundles were uploade
Workarounds: Workarounds:
- try again after the release finishes publishing - try again after the release finishes publishing
- pass the latest published version explicitly, e.g.: - pass the latest published version explicitly, e.g.:
& ([scriptblock]::Create((irm https://feynman.is/install.ps1))) -Version 0.2.20 & ([scriptblock]::Create((irm https://feynman.is/install.ps1))) -Version 0.2.24
"@ "@
} }

View File

@@ -117,13 +117,13 @@ These installers download the bundled `skills/` and `prompts/` trees plus the re
The one-line installer already targets the latest tagged release. To pin an exact version, pass it explicitly: The one-line installer already targets the latest tagged release. To pin an exact version, pass it explicitly:
```bash ```bash
curl -fsSL https://feynman.is/install | bash -s -- 0.2.20 curl -fsSL https://feynman.is/install | bash -s -- 0.2.24
``` ```
On Windows: On Windows:
```powershell ```powershell
& ([scriptblock]::Create((irm https://feynman.is/install.ps1))) -Version 0.2.20 & ([scriptblock]::Create((irm https://feynman.is/install.ps1))) -Version 0.2.24
``` ```
## Post-install setup ## Post-install setup

View File

@@ -52,9 +52,25 @@ Amazon Bedrock (AWS credential chain)
Feynman verifies the same AWS credential chain Pi uses at runtime, including `AWS_PROFILE`, `~/.aws` credentials/config, SSO, ECS/IRSA, and EC2 instance roles. Once that check passes, Bedrock models become available in `feynman model list` without needing a traditional API key. Feynman verifies the same AWS credential chain Pi uses at runtime, including `AWS_PROFILE`, `~/.aws` credentials/config, SSO, ECS/IRSA, and EC2 instance roles. Once that check passes, Bedrock models become available in `feynman model list` without needing a traditional API key.
### Local models: Ollama, LM Studio, vLLM ### Local models: LM Studio, Ollama, vLLM
If you want to use a model running locally, choose the API-key flow and then select: If you want to use LM Studio, start the LM Studio local server, load a model, choose the API-key flow, and then select:
```text
LM Studio (local OpenAI-compatible server)
```
The default settings are:
```text
Base URL: http://localhost:1234/v1
Authorization header: No
API key: lm-studio
```
Feynman attempts to read LM Studio's `/models` endpoint and prefill the loaded model id.
For Ollama, vLLM, or another OpenAI-compatible local server, choose:
```text ```text
Custom provider (baseUrl + API key) Custom provider (baseUrl + API key)
@@ -70,7 +86,7 @@ Model ids: llama3.1:8b
API key: local 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: After saving the provider, run:
```bash ```bash
feynman model list feynman model list

View File

@@ -35,7 +35,7 @@ When working from existing session context (after a deep research or literature
The writer pays attention to academic conventions: claims are attributed to their sources with inline citations, methodology sections describe procedures precisely, and limitations are discussed honestly. The draft includes placeholder sections for any content the writer cannot generate from available sources, clearly marking what needs human input. The writer pays attention to academic conventions: claims are attributed to their sources with inline citations, methodology sections describe procedures precisely, and limitations are discussed honestly. The draft includes placeholder sections for any content the writer cannot generate from available sources, clearly marking what needs human input.
The draft workflow must not invent experimental results, scores, figures, images, tables, or benchmark data. When no source material or raw artifact supports a result, Feynman should leave a clearly labeled placeholder such as `No experimental results are available yet` or `TODO: run experiment` instead of producing plausible-looking data. Drafts follow Feynman's system-wide provenance rules: unsupported results, figures, images, tables, or benchmark data should become clearly labeled gaps or TODOs, not plausible-looking claims.
## Output format ## Output format