Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ The core boundary is deliberately small: providers return normalized data with p
- **Market data:** Longbridge is the primary connector for US/HK/CN market data and brokerage portfolio access; Massive is available as a secondary US market-data provider.
- **Agent runtime:** Pi runtime for configured LLM providers, with a deterministic local provider for development and offline golden paths.
- **Desktop:** Electron with a macOS arm64 packaged build. The renderer, preload bridge, and main-process kernel are separated by context isolation and a whitelisted IPC surface.
- **Skills:** Vendored `SKILL.md` resources with references, enable/disable state, triggers, and capability requirements.
- **Skills:** Vendored `SKILL.md` resources with references, enable/disable state, triggers, and capability requirements, plus safe local user-skill installation from **Settings → Skills**.
- **Agent evaluation:** Local or LangSmith-backed evaluation records traces, datasets, evaluators, experiments, and regression gates; engineering metrics can be linked to investment outcomes without claiming causation.

## Quick Start
Expand All @@ -144,6 +144,11 @@ Download the latest macOS build from the [Releases page](https://github.com/hels
2. Connect Longbridge in **Settings → Connections** if you want live market data and portfolio access.
3. Select a symbol from the Watchlist and open **Deep Research**.

To extend the agent, open **Settings → Skills**, choose **Install from folder**,
and select a skill package containing `SKILL.md`. User skills are stored under
`~/.finagent/skills/`; bundled skills cannot be overwritten, and removing a
user skill moves its package to the system Trash.

Longbridge authentication can also be completed from the terminal:

```bash
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ Longbridge / Massive 提供商
- **行情数据:** Longbridge 是美股/港股/内地市场数据与券商投资组合访问的主要连接器;Massive 是美股市场的备选数据提供商。
- **Agent 运行时:** 为已配置的 LLM 提供商提供 Pi 运行时,另有用于开发与离线黄金路径的确定性本地提供商。
- **桌面端:** Electron,含 macOS arm64 打包构建。渲染进程、预加载桥与主进程内核通过上下文隔离与白名单化 IPC 接口面分离。
- **技能:** 内置 `SKILL.md` 资源,含引用、启用/禁用状态、触发器与能力要求。
- **技能:** 内置 `SKILL.md` 资源,含引用、启用/禁用状态、触发器与能力要求;也可在 **设置 → 技能** 中从本地文件夹安全安装用户 Skill
- **Agent 评测:** 通过本地 Evaluation Backend 或 LangSmith 记录 trace、dataset、evaluator、experiment 与 regression gate;工程指标与投资结果保持可链接但不宣称因果关系。

## 快速开始
Expand All @@ -144,6 +144,8 @@ Longbridge / Massive 提供商
2. 如需实时行情数据与投资组合访问,在 **设置 → 连接** 中连接 Longbridge。
3. 从自选清单中选择标的,打开**深度研究**。

要扩展 Agent 能力,在 **设置 → 技能** 选择“从文件夹安装”,并选中一个包含 `SKILL.md` 的 Skill 目录。用户 Skill 保存在 `~/.finagent/skills/`;内置 Skill 不会被覆盖,删除用户 Skill 时会移入系统废纸篓。

Longbridge 认证也可以在终端完成:

```bash
Expand Down
4 changes: 3 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ Longbridge / Massive 提供商
- **行情数据:** Longbridge 是美股/港股/内地市场数据与券商投资组合访问的主要连接器;Massive 是美股市场的备选数据提供商。
- **Agent 运行时:** 为已配置的 LLM 提供商提供 Pi 运行时,另有用于开发与离线黄金路径的确定性本地提供商。
- **桌面端:** Electron,含 macOS arm64 打包构建。渲染进程、预加载桥与主进程内核通过上下文隔离与白名单化 IPC 接口面分离。
- **技能:** 内置 `SKILL.md` 资源,含引用、启用/禁用状态、触发器与能力要求。
- **技能:** 内置 `SKILL.md` 资源,含引用、启用/禁用状态、触发器与能力要求;也可在 **设置 → 技能** 中从本地文件夹安全安装用户 Skill
- **Agent 评测:** 通过本地 Evaluation Backend 或 LangSmith 记录 trace、dataset、evaluator、experiment 与 regression gate;工程指标与投资结果保持可链接但不宣称因果关系。

## 快速开始
Expand All @@ -144,6 +144,8 @@ Longbridge / Massive 提供商
2. 如需实时行情数据与投资组合访问,在 **设置 → 连接** 中连接 Longbridge。
3. 从自选清单中选择标的,打开**深度研究**。

要扩展 Agent 能力,在 **设置 → 技能** 选择“从文件夹安装”,并选中一个包含 `SKILL.md` 的 Skill 目录。用户 Skill 保存在 `~/.finagent/skills/`;内置 Skill 不会被覆盖,删除用户 Skill 时会移入系统废纸篓。

Longbridge 认证也可以在终端完成:

```bash
Expand Down
13 changes: 13 additions & 0 deletions apps/electron/e2e/seed-locale.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,16 @@ export function seedLocale(userDataDir, locale = 'en-US') {
console.warn(`seedLocale: could not write preference (${String(error)})`);
}
}

/** Seed harnesses that test post-onboarding settings/workbench surfaces. */
export function seedOnboardingCompleted(userDataDir) {
try {
writeFileSync(
join(userDataDir, 'onboarding.json'),
JSON.stringify({ completed: true }),
'utf8'
);
} catch (error) {
console.warn(`seedOnboardingCompleted: could not write state (${String(error)})`);
}
}
6 changes: 5 additions & 1 deletion apps/electron/e2e/skills-interactions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
// client; the real main-process handler cannot be forced to fail here.

import { execSync, spawn } from 'node:child_process';
import { seedLocale } from './seed-locale.mjs';
import { seedLocale, seedOnboardingCompleted } from './seed-locale.mjs';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
Expand Down Expand Up @@ -99,6 +99,7 @@ async function main() {
execSync(`rm -rf "${userDataDir}"`);
execSync(`mkdir -p "${userDataDir}"`);
seedLocale(userDataDir, 'en-US');
seedOnboardingCompleted(userDataDir);
const electronProcess = spawn(
electronBinary,
[electronMain, `--remote-debugging-port=${CDP_PORT}`, '--no-sandbox'],
Expand Down Expand Up @@ -134,6 +135,7 @@ async function main() {
await page.getByRole('button', { name: 'Skills', exact: true }).click();
// Search box renders immediately (part of the loading surface).
await page.locator('[data-testid="skills-search"]').waitFor({ timeout: 15_000 });
await page.locator('[data-testid="skills-install-local"]').waitFor({ timeout: 15_000 });
// The list appears once skills load.
await page.locator('[data-testid="skill-list"]').waitFor({ timeout: 15_000 });
await row.waitFor({ timeout: 15_000 });
Expand Down Expand Up @@ -181,6 +183,8 @@ async function main() {
try {
await row.click();
await drawer.waitFor({ timeout: 15_000 });
const drawerText = (await drawer.textContent()) ?? '';
if (!drawerText.includes('Bundled')) throw new Error('bundled source label missing');
pass('S4: detail drawer opens on row click');
} catch (error) {
fail('S4: detail drawer opens on row click', error);
Expand Down
23 changes: 22 additions & 1 deletion apps/electron/src/main/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron';
import { app, BrowserWindow, dialog, ipcMain, shell, type OpenDialogOptions } from 'electron';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { AgentKernelHost, toIpcResult } from './kernelHost.ts';
Expand Down Expand Up @@ -307,6 +307,27 @@ ipcMain.handle('skills:readResource', async (_event, skillId: unknown, relativeP
toIpcResult(() => agentKernelHost.readSkillResource(skillId, relativePath))
);

ipcMain.handle('skills:installLocal', async () =>
toIpcResult(async () => {
const options: OpenDialogOptions = {
title: 'Install Folio skill',
properties: ['openDirectory'],
};
const selection = mainWindow
? await dialog.showOpenDialog(mainWindow, options)
: await dialog.showOpenDialog(options);
if (selection.canceled || selection.filePaths.length === 0) {
return { canceled: true as const };
}
const installed = await agentKernelHost.installLocalSkillDirectory(selection.filePaths[0]);
return { canceled: false as const, ...installed };
})
);

ipcMain.handle('skills:remove', async (_event, input: unknown) =>
toIpcResult(() => agentKernelHost.removeUserSkill(input))
);

// V7 Evaluation & observability (spec §61-68)
ipcMain.handle('evaluation:getSettings', async () =>
toIpcResult(() => agentKernelHost.getEvaluationSettings())
Expand Down
41 changes: 39 additions & 2 deletions apps/electron/src/main/kernelHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ import {
getLangSmithExtensionEntry,
getRuntimeRoot,
getSkillsDir,
getUserSkillsDir,
listBundledPiExtensions,
} from '@finagent/shared/resources';
import {
Expand Down Expand Up @@ -288,7 +289,10 @@ export class AgentKernelHost {
process.env.FINAGENT_PI_EXTENSION = getPiExtensionEntry();
this.credentials = new CredentialStore(join(app.getPath('userData'), 'credentials.json'));
this.skillHub = new SkillHub({
skillsDirectory: getSkillsDir(),
skillsDirectories: [
{ path: getSkillsDir(), source: 'bundled' },
{ path: getUserSkillsDir(), source: 'user' },
],
stateFile: join(app.getPath('userData'), 'skills-state.json'),
});

Expand Down Expand Up @@ -2039,7 +2043,7 @@ export class AgentKernelHost {
// -------------------------------------------------------------------------

async listSkills() {
const metadataById = new Map(this.skillHub.listSkillMetadata().map((m) => [m.id, m]));
const metadataById = new Map(this.skillHub.listAllSkillMetadata().map((m) => [m.id, m]));
return this.skillHub.listSkills().map((skill) => {
const meta = metadataById.get(skill.id);
return {
Expand All @@ -2052,10 +2056,40 @@ export class AgentKernelHost {
tier: meta?.tier,
version: meta?.version,
author: meta?.author,
source: meta?.source ?? 'bundled',
};
});
}

async installLocalSkillDirectory(sourceDirectory: unknown) {
try {
return await this.skillHub.installSkillFromDirectory(
requireString(sourceDirectory, 'sourceDirectory')
);
} catch (error) {
if (isCodeError(error)) throw error;
throw createCodeError(
'SKILL_INSTALL_FAILED',
error instanceof Error ? error.message : 'Skill installation failed.'
);
}
}

async removeUserSkill(input: unknown): Promise<void> {
const request = requireObject(input);
const skillId = requireString(request.skillId, 'skillId');
try {
await shell.trashItem(this.skillHub.userSkillDirectory(skillId));
await this.skillHub.loadSkills();
} catch (error) {
if (isCodeError(error)) throw error;
throw createCodeError(
'SKILL_REMOVE_FAILED',
error instanceof Error ? error.message : 'Skill removal failed.'
);
}
}

async setSkillEnabled(input: unknown): Promise<void> {
const request = requireObject(input);
await this.skillHub.setEnabled(
Expand Down Expand Up @@ -2101,6 +2135,9 @@ export class AgentKernelHost {
}
const env: NodeJS.ProcessEnv = {
FINAGENT_SKILLS_DIR: getSkillsDir(),
FINAGENT_SKILLS_DIRS: JSON.stringify(
this.skillHub.skillsDirectories.map((entry) => entry.path)
),
FINAGENT_PROVIDER_OVERRIDES: overrides.length > 0 ? JSON.stringify(overrides) : '',
// V7: the Finagent extension enforces tool-output privacy from this flag
// (spec §60) — always set so the level is unambiguous.
Expand Down
24 changes: 10 additions & 14 deletions apps/electron/src/preload/index.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,20 @@ var __defProp = Object.defineProperty;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __hasOwnProp = Object.prototype.hasOwnProperty;
function __accessProp(key) {
return this[key];
}
var __moduleCache = /* @__PURE__ */ new WeakMap;
var __toCommonJS = (from) => {
var entry = (__moduleCache ??= new WeakMap).get(from), desc;
var entry = __moduleCache.get(from), desc;
if (entry)
return entry;
entry = __defProp({}, "__esModule", { value: true });
if (from && typeof from === "object" || typeof from === "function") {
for (var key of __getOwnPropNames(from))
if (!__hasOwnProp.call(entry, key))
__defProp(entry, key, {
get: __accessProp.bind(from, key),
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
});
}
if (from && typeof from === "object" || typeof from === "function")
__getOwnPropNames(from).map((key) => !__hasOwnProp.call(entry, key) && __defProp(entry, key, {
get: () => from[key],
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
}));
__moduleCache.set(from, entry);
return entry;
};
var __moduleCache;

// src/preload/index.ts
var exports_preload = {};
Expand Down Expand Up @@ -129,7 +123,9 @@ var electronAPI = {
setEnabled: (input) => import_electron.ipcRenderer.invoke("skills:setEnabled", input),
listResources: (skillId) => import_electron.ipcRenderer.invoke("skills:listResources", skillId),
readResource: (skillId, relativePath) => import_electron.ipcRenderer.invoke("skills:readResource", skillId, relativePath),
readiness: () => import_electron.ipcRenderer.invoke("skills:readiness")
readiness: () => import_electron.ipcRenderer.invoke("skills:readiness"),
installLocal: () => import_electron.ipcRenderer.invoke("skills:installLocal"),
remove: (input) => import_electron.ipcRenderer.invoke("skills:remove", input)
},
about: {
get: () => import_electron.ipcRenderer.invoke("app:about")
Expand Down
4 changes: 4 additions & 0 deletions apps/electron/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ export interface ElectronAPI {
listResources: (skillId: string) => Promise<unknown>;
readResource: (skillId: string, relativePath: string) => Promise<unknown>;
readiness: () => Promise<unknown>;
installLocal: () => Promise<unknown>;
remove: (input: { skillId: string }) => Promise<unknown>;
};
about: {
get: () => Promise<unknown>;
Expand Down Expand Up @@ -283,6 +285,8 @@ const electronAPI: ElectronAPI = {
readResource: (skillId: string, relativePath: string) =>
ipcRenderer.invoke('skills:readResource', skillId, relativePath),
readiness: () => ipcRenderer.invoke('skills:readiness'),
installLocal: () => ipcRenderer.invoke('skills:installLocal'),
remove: (input: { skillId: string }) => ipcRenderer.invoke('skills:remove', input),
},
about: {
get: () => ipcRenderer.invoke('app:about'),
Expand Down
2 changes: 2 additions & 0 deletions apps/electron/src/renderer/finagentClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ function createElectronClient(): FinagentClient {
readResource: (skillId, relativePath) =>
ipcResult(window.electronAPI.skills.readResource(skillId, relativePath)),
readiness: () => ipcResult(window.electronAPI.skills.readiness()),
installLocal: () => ipcResult(window.electronAPI.skills.installLocal()),
remove: (skillId) => ipcResult(window.electronAPI.skills.remove({ skillId })),
},
about: {
get: () => ipcResult(window.electronAPI.about.get()),
Expand Down
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 16 additions & 3 deletions docs/coding-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,22 @@ When starting work on this project, Claude Code will automatically:
## Skill System

Finance Agent uses a **Skill Hub** system for extensibility. Skills are loaded
from `SKILL.md` files (`<skillsDir>/<name>/SKILL.md`) with frontmatter
(name/keywords) and can be enabled/disabled; the choice persists to
`skills-state.json`. Marketplace and editor features are out of scope for V1.
from `SKILL.md` packages in two ordered roots:

1. bundled skills shipped under the runtime resources `skills/` directory;
2. user-installed skills under `~/.finagent/skills/`.

Bundled skills are authoritative when ids collide. The Settings → Skills
screen can import a local package directory and remove user-installed packages;
bundled packages cannot be overwritten or removed. Enable/disable choices
persist to `skills-state.json`.

Local imports require a `SKILL.md` with a lowercase, hyphenated id and non-empty
`name`/`description`. The installer rejects symbolic links, special files,
packages over 1,000 files or 50 MB, and existing ids. It copies into a staging
directory before an atomic rename, so failed imports do not leave partial
packages. Removal uses the operating system Trash. A remote community catalog
and URL installation remain future work.

The current agent path runs on the persistent **Agent Kernel** in the Electron
main process:
Expand Down
9 changes: 9 additions & 0 deletions packages/i18n/src/locales/en-US/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ export const settings = {
filterPartial: 'Partial',
filterDisabled: 'Disabled',
loading: 'Loading skills…',
installLocal: 'Install from folder',
installing: 'Installing…',
installSuccess: '{{name}} installed',
retry: 'Retry',
noneInstalled: 'No skills installed',
noMatch: 'No skills match your search or filter',
Expand All @@ -105,6 +108,12 @@ export const settings = {
details: 'Details',
version: 'Version',
author: 'Author',
source: 'Source',
sourceBundled: 'Bundled',
sourceUser: 'User installed',
remove: 'Remove skill',
removing: 'Removing…',
removeDescription: 'Remove {{name}}? The package will be moved to the system Trash.',
riskLevel: 'Risk level',
tier: 'Tier',
references: 'References',
Expand Down
9 changes: 9 additions & 0 deletions packages/i18n/src/locales/zh-CN/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ export const settings = {
filterPartial: '部分',
filterDisabled: '已禁用',
loading: '正在加载技能…',
installLocal: '从文件夹安装',
installing: '正在安装…',
installSuccess: '已安装 {{name}}',
retry: '重试',
noneInstalled: '未安装技能',
noMatch: '没有符合搜索或筛选条件的技能',
Expand All @@ -99,6 +102,12 @@ export const settings = {
details: '详情',
version: '版本',
author: '作者',
source: '来源',
sourceBundled: '内置',
sourceUser: '用户安装',
remove: '删除技能',
removing: '正在删除…',
removeDescription: '要删除 {{name}} 吗?技能包会被移入系统废纸篓。',
riskLevel: '风险等级',
tier: '层级',
references: '参考资料',
Expand Down
Loading